C---秘籍-问题解决方法-二-

33 阅读23分钟

C++ 秘籍:问题解决方法(二)

原文:C++ recipes a problem-solution approach

协议:CC BY-NC-SA 4.0

三、处理文本

处理文本将是 C++ 程序员必须处理的最常见的任务之一。您可能需要读入用户输入,向用户写出消息,或者为其他程序员编写日志功能,以便更容易地调试正在运行的程序。不幸的是,处理文本不是一件容易或直接的任务。程序员太经常地匆忙工作,在他们的文本处理中犯根本性的错误,这些错误后来成为他们项目中的主要问题。最糟糕的是没有正确考虑文本字符串的本地化版本。使用英文字符集通常很容易,因为所有英文字符和标点符号都适合 ASCII 字符集。这很方便,因为表示英语所需的每个字符都可以放入一个 8 位的char变量中。一旦你的程序被要求支持外语,事情就变得混乱了。您需要支持的每个字符将不再适合单一的 8 位值。C++ 可以用多种方式处理非英语语言,我将在本章中介绍。

配方 3-1。使用文本在代码中表示字符串

问题

在调试程序时,提供输出文本通常很有用。为了做到这一点,C++ 允许你将字符串直接嵌入到你的代码中。

解决办法

C++ 程序有一个称为字符串表的概念,程序中的所有字符串都包含在程序的可执行文件中。

它是如何工作的

标准的 C++ 字符串很容易使用。清单 3-1 显示了创建一个字符串文字的代码。

清单 3-1 。字符串文字

#include <iostream>
#include <string>

using namespace std;

namespace
{
    const std::string STRING{ "This is a string"s };
}

int main()
{
    cout << STRING << endl;

    return 0;
}

本例中的字符串文字是包含在引号内的句子,后跟字母 s。编译器将在编译期间创建一个字符串表,并将它们放在一起。你可以在从图 3-1 中的源创建的 exe 文件中看到这个字符串。

9781484201589_Fig03-01.jpg

图 3-1 。来自 HxD 的屏幕截图显示了嵌入到可执行文件中的字符串文字

您可以使用字符串来初始化 STL 字符串对象。编译器会找到程序中的所有字符串,并使用字符串表中的地址来初始化字符串。你可以在清单 3-1 中看到这一点,其中指针字符串是使用字符串文字初始化的,实际上这段代码是告诉编译器将文字添加到字符串表中,并从表中获取这个特定字符串的地址,将其传递给string构造函数。

清单 3-1 中的字符串文字是一个 C++14 风格的字符串文字。旧样式的字符串文字必须小心使用,因为它们带有一些警告。首先,你不应该试图改变字符串的内容。考虑清单 3-2 中的代码。

清单 3-2 。编辑字符串文字

#include <iostream>
using namespace std;

namespace
{
    const char* const STRING{ "This is a string" };
    char* EDIT_STRING{ "Attempt to Edit" };
}

int main()
{
    cout << STRING << endl;

    cout << EDIT_STRING << endl;
    EDIT_STRING[0] = 'a';
    cout << EDIT_STRING << endl;

    return 0;
}

清单 3-2 添加了一个新的字符串文字,它被分配给一个非常数指针。main函数也有试图将字符串中的第一个字符编辑成小写字母 a 的代码。该代码将编译无误,但是您应该会收到来自 C++11/C++14 编译器的警告,因为试图使用数组操作符改变字符串是完全合法的。但是,试图改变字符串中包含的数据是一个运行时异常。尝试运行该程序会导致如图图 3-2 所示的错误。

9781484201589_Fig03-02.jpg

图 3-2 。试图改变字符串文字时产生运行时错误

通过遵循一条非常简单的建议,您可以在编译时而不是运行时发现这些错误。总是给类型const char* const的变量分配旧式的字符串文字。如果你想以一种非常直接的方式实施,你可以使用清单 3-3 中的 makefile。

清单 3-3 。编译时将警告作为错误

main: main.cpp
clang++ -Werror -std=c++1y main.cpp -o main

用清单 3-3 中的 makefile 编译你的程序将确保编译器不能用非常数字符串来构建你的应用程序。你可以在图 3-3 中看到一个输出的例子。

9781484201589_Fig03-03.jpg

图 3-3 。使用–Werror 和可写字符串进行编译时出现错误输出

字符串文字引起的第二个问题是它们增加了程序的大小。在数字世界中,减少程序的下载量是帮助增加软件安装数量的一个关键目标。删除不必要的字符串是减小可执行文件大小的一种方法。清单 3-4 展示了如何使用预处理器来实现这一点。

清单 3-4 。从构建中移除调试字符串

#include <iostream>
#include <string>

using namespace std;

#define DEBUG_STRING_LITERALS !NDEBUG

namespace
{
#if DEBUG_STRING_LITERALS
    using StringLiteral = string;
#endif

    StringLiteral STRING{ "This is a String!"s };
}

int main()
{
    cout << STRING << endl;

    return 0;
}

清单 3-4 使用NDEBUG符号创建一个预处理器符号DEBUG_STRING_LITERALSNDEBUG预处理器符号代表不调试,因此我们可以用它来决定我们是否想要在程序中包含调试字符串。然后,类型别名StringLiteral的定义被包装在#if...#endif块中,确保StringLiteral只在构建调试版本时存在。NDEBUG符号通常在 ide 中构建程序的发布版本时使用。由于本书附带的示例是使用 make 构建的,因此您必须在 makefile 中手动定义它。清单 3-5 中显示了一个 makefile 示例。

清单 3-5 。定义 NDEBUG 的 makefile

main: main.cpp
        clang++ -D NDEBUG -O2 -Werror -std=c++1y main.cpp -o main

此时,您还需要包装创建或使用任何StringLiteral类型变量的任何代码。这时你应该会发现一个问题,使用这个 define 意味着你的程序中不能有任何字符串。清单 3-6 给出了一个更好的解决方案。

清单 3-6 。分离调试和非调试字符串文字

#include <iostream>
#include <string>

using namespace std;

#define DEBUG_STRING_LITERALS !NDEBUG

namespace
{
#if DEBUG_STRING_LITERALS
    using DebugStringLiteral = string;
#endif

#if DEBUG_STRING_LITERALS
    DebugStringLiteral STRING{ "This is a String!"s };
#endif
}

int main()
{
#if DEBUG_STRING_LITERALS
    cout << STRING << endl;
#endif

    return 0;
}

对诊断代码使用调试文字,如清单 3-6 所示,最终用户永远不会看到,这允许您删除字符串和代码,从而减少可执行文件的大小并提高执行速度。

食谱 3-2。本地化面向用户的文本

问题

你永远不知道什么时候你可能需要支持一种除了你母语之外的语言。确保用户可以看到的任何字符串都来自本地化的源。

解决办法

构建一个字符串管理器类,它从自己创建的表中返回字符串,并且只引用使用 id 的字符串。

它是如何工作的

通过使用您在源代码中定义为字符串文字的字符串与用户通信,您可以合法地对整个项目进行编码。这有几个主要缺点。首先,很难即时切换语言。今天,你的软件很可能会通过互联网发布。你的程序不被和你说不同语言的人使用的可能性非常小。在大型开发团队中,开发团队中的人有可能拥有不同的第一语言。从一开始就在你的程序中建立本地化文本的能力将会在以后为你省去很多麻烦。这是通过从文件中为你的程序加载字符串数据来实现的。然后,您可以在数据中包含多种不同的语言,方法是用您的母语编写字符串,并让朋友或翻译服务为您将字符串翻译成其他语言。

您需要创建一个类来处理游戏的本地化字符串内容。清单 3-7 显示了本地化管理器的类定义。

清单 3-7 。本地化经理

#pragma once
#include <array>
#include <cinttypes>
#include <string>
#include <unordered_map>

namespace Localization
{
    using StringID = int32_t;

    enum class Languages
    {
        EN_US,
        EN_GB,
        Number
    };

    const StringID STRING_COLOR{ 0 };

    class Manager
    {
    private:
        using Strings = std::unordered_map<StringID, std::string>;
        using StringPacks =
            std::array<Strings, static_cast<size_t>(Languages::Number)>;

        StringPacks m_StringPacks;
        Strings* m_CurrentStringPack{ nullptr };

        uint32_t m_LanguageIndex;

    public:
        Manager();

        void SetLanguage(Languages language);

        std::string GetString(StringID stringId) const;
    };
}

清单 3-7 中做了很多事情。源代码需要注意的第一个方面是名称空间。如果您在名称空间中保存不同的类,并且这些类的名称有意义,那么您会发现管理代码会更容易。对于本地化模块,我使用了名称Localization。当你使用这个模块中的类和对象时,这将有助于在你的代码中清楚地表达出来。

创建了一个类型别名作为不同字符串的标识符。同样,类型别名在这里很有用,因为您可能会在将来的某个时候决定更改字符串 id 的类型。有一个enum class 决定了本地化管理器支持的语言。StringID STRING_COLOR被定义为 0。这是本例中唯一的StringID,因为它是我们说明本地化管理器如何操作所需要的全部内容。

Manager本身定义了一些私有类型别名来使代码清晰。有一个定义的别名允许我们创建一个unordered_map of StringID to std::string对,另一个允许创建这些字符串映射的数组。还声明了一个变量来实例化一个字符串映射数组以及一个指向当前正在使用的字符串映射的指针。这个类有一个构造函数和另外两个方法,SetLanguageGetString。清单 3-8 显示了构造函数的源代码。

清单 3-8 。本地化::管理器构造函数

Manager::Manager()
{
    static const uint32_t INDEX_EN_US{ static_cast<uint32_t>(Languages::EN_US) };
    m_StringPacks[INDEX_EN_US][STRING_COLOR] = "COLOR"s;

    static const uint32_t INDEX_EN_GB{ static_cast<uint32_t>(Languages::EN_GB) };
    m_StringPacks[INDEX_EN_GB][STRING_COLOR] = "COLOUR"s;

    SetLanguage(Languages::EN_US);
}

这个基本构造函数初始化两个字符串映射,一个用于美国英语,一个用于英国英语。您可以看到单词 color 的不同拼写被传递到每个地图中。源代码的最后一行将默认语言设置为美国英语。SetLanguage方法如清单 3-9 所示。

清单 3-9 。本地化::Manager::SetLanguage

void Manager::SetLanguage(Languages language)
{
    m_CurrentStringPack = &(m_StringPacks[static_cast<uint32_t>(language)]);
}

这个方法很简单。它只是设置m_CurrentStringPack变量来存储所选语言的字符串映射的地址。你必须static_cast枚举类型变量,因为 C++ 的 STL 数组不允许你使用非数值类型的索引。您可以看到static_cast正在将语言参数转换为uint32_t

Manager类中的最后一个方法是GetString方法,你可以在清单 3-10 中看到。

清单 3-10 。本地化::Manager::GetString

std::string Manager::GetString(StringID stringId) const
{
    stringstream resultStream;
    resultStream << "!!!"s;
    resultStream << stringId;
    resultStream << "!!!"s;
    string result{ resultStream.str() };

    auto iter = m_CurrentStringPack->find(stringId);
    if (iter != m_CurrentStringPack->end())
    {
        result = iter->second;
    }

    return result;
}

GetString方法从构建从函数返回的默认字符串开始。这将允许您打印出程序中任何丢失的字符串 id,以帮助本地化测试工作。然后使用unordered_map::find方法在映射中搜索字符串 id。如果返回一个有效的iterator,你就知道find调用是否成功。如果搜索没有找到匹配,它将返回end iteratorif语句检查是否在映射中找到了字符串 id。如果找到了,给定 id 的字符串将存储在result变量中,并传递回方法调用方。

Image 注意你可以让缺省的缺失字符串只发生在非最终版本中。这将节省在最终用户的计算机上构建这个字符串的执行成本。他们应该希望永远不会在他们的程序中看到丢失的字符串。

清单 3-11 列出了一个更新的main函数,展示了如何在你的代码中使用这个Manager

清单 3-11 。使用Localization::Manager class

#include <iostream>
#include "LocalizationManager.h"

using namespace std;

int main()
{
    Localization::Manager localizationManager;
    string color{ localizationManager.GetString(Localization::STRING_COLOR) };
    cout << "EN_US Localized string: " << color.c_str() << endl;

    localizationManager.SetLanguage(Localization::Languages::EN_GB);
    color = localizationManager.GetString(Localization::STRING_COLOR);
    cout << "EN_GB Localized string: " << color.c_str() << endl;

    color = localizationManager.GetString(1);
    cout << color.c_str() << endl;

    return 0;
}

main函数现在创建了一个Localization::Manager类的实例。您可以看到一个如何从管理器中检索字符串并使用cout将其输出的示例。然后,语言被切换到英国英语,字符串被检索并被第二次打印。为了完整起见,最后一个例子显示了当您请求一个不存在的字符串 id 时会发生什么。图 3-4 包含程序的输出。

9781484201589_Fig03-04.jpg

图 3-4 。本地化管理器字符串的输出

下图显示了您所期望的输出。首先显示颜色的美国英语拼写,然后是英国英语拼写,最后输出缺少的 id,在开头和结尾有三个感叹号。这将有助于突出程序中缺少的字符串标识符。

食谱 3-3。从文件中读取字符串

问题

在源代码中嵌入面向用户的文本会使将来的文本更新和本地化难以管理。

解决办法

您可以从数据文件中加载本地化字符串数据。

它是如何工作的

我将向您展示如何将字符串数据从逗号分隔的值(csv)文件。在加载这样的文件之前,您需要创建一个。图 3-5 显示了我输入 Excel 导出为. csv 文件的数据。

9781484201589_Fig03-05.jpg

图 3-5 。Excel 2013 中的字符串. csv 文件

我已经用 Excel 创建了一个非常基本的。csv 文件。你可以看到我在上一节中使用的颜色和颜色值,以及美国和英国风味的拼写。图 3-6 显示了该文件如何出现在基本文本编辑器中。

9781484201589_Fig03-06.jpg

图 3-6 。strings.csv 文件在 Notepad++ 中打开

Excel 文档中的每一行都放在 csv 文件中自己的行中,每一列都用逗号分隔。这也是 csv 得名的原因。现在我们有了一个 csv 文件,我们可以将数据加载到Localization::Manager的构造函数中。清单 3-12 包含代码,可用于加载和解析字符串 csv 文件。

清单 3-12 。从 csv 加载字符串

Manager::Manager()
{
    ifstream csvStringFile{ "strings.csv"s };

    assert(csvStringFile);
    if (csvStringFile)
    {
        while (!csvStringFile.eof())
        {
            string line;
            getline(csvStringFile, line);

            if (line.size() > 0)
            {
                // Create a stringstream for the line
                stringstream lineStream{ line };

                // Use the line stream to read in the string id
                string stringIdText;
                getline(lineStream, stringIdText, ',');

                stringstream idStream{ stringIdText };
                uint32_t stringId;
                idStream >> stringId;

                // Loop over the line and read in each string
                uint32_t languageId = 0;
                string stringText;
                while (getline(lineStream, stringText, ','))
                {
                    m_StringPacks[languageId++][stringId] = stringText;
                }
            }
        }
    }

    SetLanguage(Languages::EN_US);
}

在 strings.csv 文件中读取的代码并不太复杂。第一步是打开文件进行读取,代码使用一个ifstream对象来实现。C++ 提供了ifstream类来从文件中读取数据,并提供了实现这一点的方法。我们使用的第一个方法是重载指针操作符。当我们使用assertif来确定传入ifstream的文件是否有效并被打开时,就会调用这个函数。接下来是一个 while 循环,它将一直运行到文件结束或者eof方法返回 true。这是理想的,因为我们不希望在所有的字符串都被加载之前停止读取数据。

ifstream类提供了一个getline方法,可以用于 C 风格的字符串数组。一般来说,使用std::string比使用原始的 C 字符串更好,更不容易出错,所以在清单 3-12 中,你可以看到std::getline方法的使用,它引用任何类型的流。getline 的第一个用途是将 csv 文件中的一整行文本检索到一个std::string对象中。这一行包含关于单个字符串的数据,以其 id 开始,后面是文本的每个本地化版本。

s td::getline方法有一个非常有用的第三个参数。默认情况下,该方法从文件中检索文本,直到它到达一个换行符,但是我们可以传入一个不同的字符作为第三个参数,当遇到这个字符时,该函数将停止收集文本。清单 3-11 通过传入一个逗号作为分隔符来利用这个特性。这允许我们从 Excel 文档的每个单元格中提取值。

getline函数需要一个流对象传递给它,但是该行被读入到std::string中。你可以看到这个问题是通过创建一个stringstream对象并将 line 变量传递给构造函数来解决的。一旦创建了 stringstream,就使用 getline 方法通过一个stringstream对象检索字符串 id。

Image C++ 提供了几种将字符串转换成数值的方法。这些包括转换成整数的stoi和转换成浮点数的stof以及其他。这些都在字符串头文件中定义。你还可以在那里找到一个函数named to_string,它可以用来将几种不同的类型转换成一个字符串。您可能正在使用的 STL 的实现并不总是提供这些。例如,Cygwin 中当前可用的 libstdc++ 版本不提供这些函数,因此代码示例没有使用它们。

在该方法检索到 id 之后,它循环遍历该行的其余部分,并读出每种语言的字符串数据。这依赖于Languages enum class定义的语言顺序与 csv 文件中的列顺序相同。

配方 3-4。从 XML 文件中读取数据

问题

虽然 CSV 文件是一种非常简单的格式,对于某些应用程序来说非常好,但是它们有一个主要的缺陷;用逗号分隔字符串意味着不能在字符串数据中使用逗号,因为加载代码会将它们解释为字符串的结尾。如果发生这种情况,代码可能会崩溃,因为它试图读入太多的字符串,使数组溢出。

解决办法

将字符串文件保存为 XML 文档,并使用解析器加载数据。

它是如何工作的

RapidXML 库是一个开源的 XML 解决方案,可以用于您的 C++ 应用程序。它以头文件的形式提供,可以包含在任何需要 XML 处理能力的源文件中。您可以从以下位置http://rapidxml.sourceforge.net/下载 RapidXML 的最新版本。我使用 XML Spreadsheet 2003 文件类型保存了我的 Excel 文档。本节中显示的代码能够加载这种类型的 XML 文件。清单 3-13 显示了包含我们的字符串数据的整个文件。

清单 3-13 。XML 电子表格文件

<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook
 xmlns:o="urn:schemas-microsoft-com:office:office"
 xmlns:x="urn:schemas-microsoft-com:office:excel"
 xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
 xmlns:html="http://www.w3.org/TR/REC-html40">
 <DocumentProperties >
  272103_1_EnBruce Sutherland</Author>
  <LastAuthor>Bruce</LastAuthor>
  <Created>2014-06-13T06:29:44Z</Created>
  <Version>15.00</Version>
 </DocumentProperties>
 <OfficeDocumentSettings >
  <AllowPNG/>
 </OfficeDocumentSettings>
 <ExcelWorkbook >
  <WindowHeight>12450</WindowHeight>
  <WindowWidth>28800</WindowWidth>
  <WindowTopX>0</WindowTopX>
  <WindowTopY>0</WindowTopY>
  <ProtectStructure>False</ProtectStructure>
  <ProtectWindows>False</ProtectWindows>
 </ExcelWorkbook>
 <Styles>
  <Style ss:ID="Default" ss:Name="Normal">
   <Alignment ss:Vertical="Bottom"/>
   <Borders/>
   <Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="11" ss:Color="#000000"/>
   <Interior/>
   <NumberFormat/>
   <Protection/>
  </Style>
 </Styles>
 <Worksheet ss:Name="strings">
  <Table ss:ExpandedColumnCount="3" ss:ExpandedRowCount="2" x:FullColumns="1"
   x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="14.25">
   <Row>
    <Cell><Data ss:Type="Number">0</Data></Cell>
    <Cell><Data ss:Type="String">Color</Data></Cell>
    <Cell><Data ss:Type="String">Colour</Data></Cell>
   </Row>
   <Row>
    <Cell><Data ss:Type="Number">1</Data></Cell>
    <Cell><Data ss:Type="String">Flavor</Data></Cell>
    <Cell><Data ss:Type="String">Flavour</Data></Cell>
   </Row>
  </Table>
  <WorksheetOptions >
   <PageSetup>
    <Header x:Margin="0.3"/>
    <Footer x:Margin="0.3"/>
    <PageMargins x:Bottom="0.75" x:Left="0.7" x:Right="0.7" x:Top="0.75"/>
   </PageSetup>
   <Selected/>
   <ProtectObjects>False</ProtectObjects>
   <ProtectScenarios>False</ProtectScenarios>
  </WorksheetOptions>
 </Worksheet>
</Workbook>

从这个文件清单中,您可能会发现我们的解析代码需要忽略大量的数据。从文档根开始,我们将通过工作簿节点访问字符串数据,然后是工作表、表、行、单元格,最后是数据节点。

Image 注意这种 XML 数据格式非常冗长,不必要的数据有点多。使用 Excel 的 Visual Basic for Applications 宏支持编写自己的轻量级导出器会更好,但这个主题超出了本书的范围。

清单 3-14 涵盖了使用 RapidXML 加载字符串数据所需的代码。

清单 3-14 。使用 RapidXML 加载字符串

Manager::Manager()
{
    ifstream xmlStringFile{ "strings.xml"s };
    xmlStringFile.seekg(0, ios::end);
    uint32_t size{ static_cast<uint32_t>(xmlStringFile.tellg()) + 1 };
    char* buffer{ new char[size]{} };
    xmlStringFile.seekg(0, ios::beg);
    xmlStringFile.read(buffer, size);
    xmlStringFile.close();

    rapidxml::xml_document<> document;
    document.parse<0>(buffer);

    rapidxml::xml_node<>* workbook{ document.first_node("Workbook") };
    if (workbook != nullptr)
    {
        rapidxml::xml_node<>* worksheet{ workbook->first_node("Worksheet") };
        if (worksheet != nullptr)
        {
            rapidxml::xml_node<>* table{ worksheet->first_node("Table") };
            if (table != nullptr)
            {
                rapidxml::xml_node<>* row{ table->first_node("Row") };
                while (row != nullptr)
                {
                    uint32_t stringId{ UINT32_MAX };

                    rapidxml::xml_node<>* cell{ row->first_node("Cell") };
                    if (cell != nullptr)
                    {
                        rapidxml::xml_node<>* data{ cell->first_node("Data") };
                        if (data != nullptr)
                        {
                            stringId = static_cast<uint32_t>(atoi(data->value()));
                        }
                }

                if (stringId != UINT32_MAX)
                {
                    uint32_t languageIndex{ 0 };

                    cell = cell->next_sibling("Cell");
                    while (cell != nullptr)
                    {
                        rapidxml::xml_node<>* data = cell->first_node("Data");
                        if (data != nullptr)
                        {
                            m_StringPacks[languageIndex++][stringId] = data->value();
                        }

                        cell = cell->next_sibling("Cell");
                    }
                }

                row = row->next_sibling("Row");
            }
        }
    }
}

这个清单有很多内容,所以我将一节一节地分解。第一步是使用下面的代码将 XML 文件的全部内容加载到内存中。

ifstream xmlStringFile{ "strings.xml"s };
xmlStringFile.seekg(0, ios::end);
uint32_t size{ static_cast<uint32_t>(xmlStringFile.tellg()) + 1 };
char* buffer{ new char[size]{} };
xmlStringFile.seekg(0, ios::beg);
xmlStringFile.read(buffer, size);
xmlStringFile.close();

你需要将整个文件存储在一个空终止的内存缓冲区中,这就是为什么使用ifstream打开文件,然后使用seekg移动到流的末尾。一旦结束,tellg方法可以用来计算出文件有多大。来自tellg的值加 1,以确保有足够的内存分配给 RapidXML 要求的空终止字符。动态内存分配用于在内存中创建缓冲区,而memset清除整个缓冲区以包含零。seekg方法用于在 read 用于将文件的全部内容获取到分配的缓冲区之前,将文件流位置移动到文件的开头。最后一步是在代码处理完文件后立即关闭文件流。

这两行负责从文件内容初始化 XML 数据结构。

rapidxml::xml_document<> document;
document.parse<0>(buffer);

这段代码创建了一个包含解析方法的 XML 文档对象。作为模板参数传递的 0 可用于在解析器上设置不同的标志,但本例不需要这些标志。现在代码已经创建了 XML 文档的解析表示,可以开始访问它包含的节点了。接下来的几行检索指向工作簿、工作表、表和行节点的指针。

rapidxml::xml_node<>* workbook{ document.first_node("Workbook") };
if (workbook != nullptr)
{
    rapidxml::xml_node<>* worksheet{ workbook->first_node("Worksheet") };
    if (worksheet != nullptr)
    {
        rapidxml::xml_node<>* table{ worksheet->first_node("Table") };
       if (table != nullptr)
        {
            rapidxml::xml_node<>* row{ table->first_node("Row") };
            while (row != nullptr)
            {

这些线都是直的。在一个简单的 Excel XML 文档中只有一个工作簿、工作表和表格,因此我们可以简单地向每个节点请求该名称的第一个子节点。一旦代码到达行元素,就会有一个 while 循环。这将允许我们检查电子表格中的每一行,并将我们的字符串加载到适当的映射中。整行 while 循环如下。

rapidxml::xml_node<>* row{ table->first_node("Row") };
while (row != nullptr)
{
    uint32_t stringId{ UINT32_MAX };

    rapidxml::xml_node<>* cell{ row->first_node("Cell") };
    if (cell != nullptr)
    {
        rapidxml::xml_node<>* data{ cell->first_node("Data") };
        if (data != nullptr)
        {
            stringId = static_cast<uint32_t>(atoi(data->value()));
        }
    }

    if (stringId != UINT32_MAX)
    {
        uint32_t languageIndex{ 0 };

        cell = cell->next_sibling("Cell");
        while (cell != nullptr)
        {
            rapidxml::xml_node<>* data = cell->first_node("Data");
            if (data != nullptr)
            {
                m_StringPacks[languageIndex++][stringId] = data->value();
            }

            cell = cell->next_sibling("Cell");
        }
    }

    row = row->next_sibling("Row");
}

while循环从从第一个单元和数据节点获取stringId开始。atoi函数用于将 C 风格的字符串转换成一个必须转换成unsigned int 的整数。下面的if检查是否获得了有效的字符串 id,如果是,则代码进入另一个while循环。这个循环从后续的单元格和数据节点中获取每个字符串,并将它们放入正确的映射中。它通过最初将语言索引设置为 0,并在输入每个字符串后递增索引来实现这一点。这再次要求将本地化的字符串以正确的顺序输入到电子表格中。

这就是从 XML 文件加载字符串数据所需的全部内容。您应该能够找到一种更好的方法来生成这些文件,而不会消耗太多的数据。您可能还会遇到这样的情况:加载所有文本会消耗太多系统内存。此时,您应该考虑将每种语言拆分到一个单独的文件中,并且只在需要时才加载这些语言。用户不太可能需要您选择支持的每一种翻译语言。

配方 3-5。将运行时数据插入字符串

问题

有时,您需要在字符串中输入运行时数据,如数字或用户名。虽然 C++ 支持旧的 C 函数来格式化 C 风格的字符串,但是这些函数不能处理 STL 的string类。

解决办法

boost 库为 C++ 提供了广泛的库支持,包括用于格式化 STL 字符串中保存的数据的方法和函数。

它是如何工作的

首先,您应该在电子表格中添加一个包含以下数据的新行;2, %1% %2%, %2% %1%.您应该将逗号后面的每个元素放在新的单元格中。清单 3-15 更新了 main 函数以利用这个新的字符串。

清单 3-15 。使用boost::format

#include <iostream>
#include "LocalizationManager.h"
#include "boost/format.hpp"

using namespace std;

int main()
{
    Localization::Manager localizationManager;
    std::string color{ localizationManager.GetString(Localization::STRING_COLOR) };
    std::cout << "EN_US Localized string: " << color.c_str() << std::endl;

    std::string flavor{ localizationManager.GetString(Localization::STRING_FLAVOR) };
    std::cout << "EN_US Localized string: " << flavor.c_str() << std::endl;

    localizationManager.SetLanguage(Localization::Languages::EN_GB);
    color = localizationManager.GetString(Localization::STRING_COLOR);
    std::cout << "EN_GB Localized string: " << color.c_str() << std::endl;

    flavor = localizationManager.GetString(Localization::STRING_FLAVOR);
    std::cout << "EN_GB Localized string: " << flavor.c_str() << std::endl;

    color = localizationManager.GetString(3);
    std::cout << color.c_str() << std::endl;

    std::cout << "Enter your first name: " << std::endl;
    std::string firstName;
    std::cin >> firstName;

    std::cout << "Enter your surname: " << std::endl;
    std::string surname;
    std::cin >> surname;

    localizationManager.SetLanguage(Localization::Languages::EN_US);
    std::string formattedName{ localizationManager.GetString(Localization::STRING_NAME) };
    formattedName = str( boost::format(formattedName) % firstName % surname );
    std::cout << "You said your name is: " << formattedName << std::endl;

    localizationManager.SetLanguage(Localization::Languages::EN_GB);
    formattedName = localizationManager.GetString(Localization::STRING_NAME);
    formattedName = str(boost::format(formattedName) % firstName % surname);
    std::cout << "You said your name is: " << formattedName << std::endl;

    return 0;
}

你可以看到在清单 3-15 中添加的main附件要求用户输入他们自己的名字。对cin的调用将停止程序执行,直到用户输入他们的名字和姓氏。一旦程序存储了用户名,它就将语言更改为 EN_US,并从本地化管理器获取字符串。下一行使用 b oost::format函数将字符串中的符号替换为firstNamesurname值。我们的新字符串包含符号%1%和%2%。这用于决定将哪些变量替换到字符串中。对format的调用后跟一个%操作符,然后是firstName字符串。因为 firstName 是传递给%运算符的第一个参数,所以它将替换字符串中的%1%。类似地,姓氏将用于替换%2%,因为它是使用%传递的第二个参数。

这都是因为format函数正在设置一个从format函数返回的对象。然后这个对象被传递给它的%操作符,后者将值存储在firstName中。对操作符%的第一次调用返回对 boost format对象的引用,该对象被传递给对操作符%的第二次调用。直到 format 对象被传递到str函数中,源字符串中的符号才被真正解析。Boost 在全局名称空间中声明了str函数,因此它不需要名称空间范围操作符。str方法获取格式对象并构造一个新的string,将参数替换到适当的位置。当您将源字符串输入到电子表格中时,EN_GB 字符串的名称被调换了。你可以在图 3-7 中看到代码的结果。

9781484201589_Fig03-07.jpg

图 3-7 。来自boost::format的输出

您可以使用boost::format将各种数据替换成字符串。不幸的是,boost 并不遵循与标准 C printf函数相同的约定,因此你需要对标准 C 程序使用不同的字符串。boost 提供的格式化选项的完整列表可以在http://www.boost.org/doc/libs/1_55_0/libs/format/doc/format.html找到。

在程序中包含 boost/format.hpp 头文件所需的 makefile 相对来说比较简单。你可以在清单 3-16 中看到。

清单 3-16 。包括 Boost 库

main: main.cpp LocalizationManager.cpp
        clang++ -g -std=c++1y -Iboost_1_55_0 main.cpp LocalizationManager.cpp -o main

您可以从这个 makefile 中看到,我使用的是 1.55 版本的 Boost 库,并且我将该文件夹放在了与我的 makefile 相同的文件夹中。包含 Boost 头文件的约定是在 include 指令中命名 Boost 文件夹,因此 clang++ 命令中的–I 开关只是告诉编译器查看 boost_1_55_0 文件夹内部。boost 文件夹位于该文件夹中。

四、处理数字

计算机是为处理数字而设计和制造的。您编写的程序将利用计算机的计算能力,为完全依赖于您理解和利用 C++ 提供的工具来处理数字的用户提供体验。C++ 支持不同类型的数字,这种支持包括整数和实数,以及多种不同的存储和表示方式。

C++ 整数类型将用于存储整数,浮点类型将用于存储带小数点的实数。在 C++ 中使用每种类型的数字时,都有不同的权衡和考虑,本章将向您介绍每种类型适用的不同挑战和场景。您还将看到一种更古老的技术,称为定点算术,它可以使用整数类型来近似浮点类型。

配方 4-1。在 C++ 中使用整数类型

问题

您需要在程序中表示整数,但是不确定不同整数类型的限制和能力。

解决办法

了解 C++ 支持的不同整数类型,将允许您为手头的任务使用正确的类型。

它是如何工作的

使用 int 类型

C++ 提供了现代处理器支持的不同整数类型的精确表示。所有整数类型的行为方式完全相同,但是它们可能包含比彼此更多或更少的数据。清单 4-1 展示了如何在 C++ 中定义一个整数变量。

清单 4-1 。定义整数

int main(int argc, char* argv[])
{
    int wholeNumber{ 64 };
    return 0;
}

如你所见,在 C++ 中整数是用int类型定义的。C++ 中的int类型可以与标准算术运算符结合使用,这些运算符允许您进行加、减、乘、除和取模运算。清单 4-2 使用这些操作符来初始化额外的整数变量。

清单 4-2 。使用运算符初始化整数

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    int wholeNumber1{ 64 };
    cout << "wholeNumber1 equals " << wholeNumber1 << endl;

    int wholeNumber2{ wholeNumber1 + 32 };
    cout << "wholeNumber2 equals " << wholeNumber2 << endl;

    int wholeNumber3{ wholeNumber2 - wholeNumber1 };
    cout << "wholeNumber3 equals " << wholeNumber3 << endl;

    int wholeNumber4{ wholeNumber2 * wholeNumber1 };
    cout << "wholeNumber4 equals " << wholeNumber4 << endl;

    int wholeNumber5{ wholeNumber4 / wholeNumber1 };
    cout << "wholeNumber5 equals " << wholeNumber5 << endl;

    int wholeNumber6{ wholeNumber4 % wholeNumber1 };
    cout << "wholeNumber6 equals " << wholeNumber6 << endl;

    return 0;
}

清单 4-2 中的代码包含使用操作符初始化额外整数的行。运算符有多种用法。您可以看到,操作符的两边可以有文字值(如 32)或其他变量。图 4-1 显示了该程序的输出。

9781484201589_Fig04-01.jpg

图 4-1 。运行清单 4-2 中代码的输出

清单 4-2 的输出如图图 4-1 所示。下面的列表解释了输出中显示的值如何出现在每个变量中。

  • 变量wholeNumber1用值 64 初始化,因此输出是 64。
  • 文字 32 被加到wholeNumber1的值上,并存储在wholeNumber2中,因此输出在 96 中。
  • 下一行输出 32,因为代码已经从wholeNumber1中减去了wholeNumber2。这样做的效果是,我们成功地将初始化wholeNumber2得到的文字值存储在变量wholeNumber3中。
  • wholeNumber4的值输出为 64*96 的结果 6144。
  • 程序打印出wholeNumber5的值 96,因为它是 6144 除以 64 的结果,或者是wholeNumber4的值除以wholeNumber1的值。
  • wholeNumber6的值输出为 32。模运算符返回除法的余数。在这种情况下,96/64 的余数是 32,因此模运算符返回 32。

使用不同类型的整数

C++ 编程语言支持不同类型的整数。表 4-1 显示了不同类型的整数及其属性。

表 4-1 。C++ 整数类型

Table4-1.jpg

表 4-1 列出了 C++ 提供的处理整数的五种主要类型。C++ 提出的问题是,这些类型并不总是保证表示如表 4-1 所示的字节数。这是因为 C++ 标准将多少字节表示的决定权留给了平台。这种情况不完全是 C++ 的错。处理器制造商可以选择使用不同的字节数来表示整数,因此这些平台的编译器作者可以根据标准自由地改变类型以适合他们的处理器。然而,您可以通过使用cinttypes头来编写保证整数中字节数的代码。表 4-2 显示了通过cinttypes可获得的不同整数。

表 4-2 。cinttypes整数

Table4-2.jpg

cinttypes提供的类型包含它们所代表的位数。假设一个字节中有 8 位,你可以在表 4-2 中看到类型和字节数的关系。清单 4-3 使用与清单 4-2 相同的操作符,但更新后使用int32_t类型代替int

清单 4-3 。使用带有运算符的int32_t类型

#include <iostream>
#include <cinttypes>

using namespace std;

int main(int argc, char* argv[])
{
    int32_t whole32BitNumber1{ 64 };
    cout << "whole32BitNumber1 equals " << whole32BitNumber1 << endl;

    int32_t whole32BitNumber2{ whole32BitNumber1 + 32 };
    cout << "whole32BitNumber2 equals " << whole32BitNumber2 << endl;

    int32_t whole32BitNumber3{ whole32BitNumber2 - whole32BitNumber1 };
    cout << "whole32BitNumber3 equals " << whole32BitNumber3 << endl;

    int32_t whole32BitNumber4{ whole32BitNumber2 * whole32BitNumber1 };
    cout << "whole32BitNumber4 equals " << whole32BitNumber4 << endl;

    int32_t whole32BitNumber5{ whole32BitNumber4 / whole32BitNumber1 };
    cout << "whole32BitNumber5 equals " << whole32BitNumber5 << endl;

    int whole32BitNumber6{ whole32BitNumber2 % whole32BitNumber1 };
    cout << "whole32BitNumber6 equals " << whole32BitNumber6 << endl;

    return 0;
}

从图 4-2 中可以看到,该代码产生的输出类似于图 4-1 的输出。

9781484201589_Fig04-02.jpg

图 4-2 。使用清单 4-2 中的int32_t和代码时的输出

使用无符号整数

在表 4-1 和表 4-2 中显示的每种类型都有无符号的对应物。使用该类型的无符号版本意味着您将不再能够访问负数,但是您将拥有由相同字节数表示的更大范围的正数。你可以在表 4-3 中看到 C++ 标准无符号类型。

表 4-3 。C++ 的内置无符号类型

Table4-3.jpg

无符号数存储的数字范围与其有符号数相同。一个signed char和一个unsigned char都可以存储 256 个唯一值。signed char存储从-128 到 127 的值,而unsigned版本存储从 0 到 255 的 256 个值。内置的无符号类型与有符号类型面临同样的问题,它们在不同的平台上可能表示不同的字节数。C++ 的cinttypes头文件提供了保证其大小的无符号类型。表 4-4 记录了这些类型。

表 4-4 。cintypes 头文件的无符号整数类型

Table4-4.jpg

食谱 4-2。用关系运算符做决策

问题

你正在编写一个程序,必须根据两个值的比较结果做出决定。

解决办法

C++ 提供了基于计算的比较返回 true 或 false 的关系运算符。

它是如何工作的

C++ 提供了四种主要的关系运算符 。这些是:

  • 相等运算符
  • 不等式算子
  • 大于号运算符
  • 小于运算符

这些运算符允许您快速比较两个值,并确定结果是真还是假。真或假比较的结果可以存储在 C++ 提供的bool类型中。一个bool只能代表true或者false

相等运算符

清单 4-4 显示了使用中的等式运算符 。

清单 4-4 。C++ 相等运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    int32_t equal1{ 10 };
    int32_t equal2{ 10 };
    bool isEqual = equal1 == equal2;
    cout << "Are the numbers equal? " << isEqual << endl;

    int32_t notEqual1{ 10 };
    int32_t notEqual2{ 100 };
    bool isNotEqual = notEqual1 == notEqual2;
    cout << "Are the numbers equal? " << isNotEqual << endl;

    return 0;
}

清单 4-4 中的代码生成如图图 4-3 所示的输出。

9781484201589_Fig04-03.jpg

图 4-3 。关系等式运算符的输出

如果运算符两边的值相同,等式运算符会将 bool 变量的值设置为 true(在输出中表示为 1)。这是清单 4-4 比较equal1equal2的情况。当两边的值不同时,如代码比较notEqual1notEqual2时,运算符的结果为假。

不等式算子

不等式运算符 用于判断数字何时不相等。清单 4-5 展示了不等式操作符的使用。

清单 4-5 。不等式算子

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    int32_t equal1{ 10 };
    int32_t equal2{ 10 };
    bool isEqual = equal1 != equal2;
    cout << "Are the numbers not equal? " << isEqual << endl;

    int32_t notEqual1{ 10 };
    int32_t notEqual2{ 100 };
    bool isNotEqual = notEqual1 != notEqual2;
    cout << "Are the numbers not equal? " << isNotEqual << endl;

    return 0;
}

清单 4-5 产生的输出如图图 4-4 所示。

9781484201589_Fig04-04.jpg

图 4-4 。清单 4-5 的输出显示了不等式运算符的结果

从清单 4-5 和图 4-4 可以看出,当值不相等时不等式运算符将返回 true,当值相等时返回 false。

大于号运算符

大于运算符 可以告诉你左边的数字是否大于右边的数字。清单 4-6 展示了这一点。

清单 4-6 。大于号运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    int32_t greaterThan1{ 10 };
    int32_t greaterThan2{ 1 };
    bool isGreaterThan = greaterThan1 > greaterThan2;
    cout << "Is the left greater than the right? " << isGreaterThan << endl;

    int32_t notGreaterThan1{ 10 };
    int32_t notGreaterThan2{ 100 };
    bool isNotGreaterThan = notGreaterThan1 > notGreaterThan2;
    cout << "Is the left greater than the right? " << isNotGreaterThan << endl;

    return 0;
}

大于运算符将 bool 的值设置为 true 或 false。当左边的数字大于右边的数字时,结果为真;当右边的数字大于左边的数字时,结果为假。图 4-5 显示了清单 4-6 生成的输出。

9781484201589_Fig04-05.jpg

图 4-5 。清单 4-6 生成的输出

小于运算符

小于运算符产生与大于运算符相反的结果。当左边的数字小于右边的数字时,小于运算符返回 true。清单 4-7 显示了正在使用的操作符。

清单 4-7 。小于运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    int32_t lessThan1{ 1 };
    int32_t lessThan2{ 10 };
    bool isLessThan = lessThan1 < lessThan2;
    cout << "Is the left less than the right? " << isLessThan << endl;

    int32_t notLessThan1{ 100 };
    int32_t notLessThan2{ 10 };
    bool isNotLessThan = notLessThan1 < notLessThan2;
    cout << "Is the left less than the right? " << isNotLessThan << endl;

    return 0;
}

图 4-6 显示了执行清单 4-7 中的代码时的结果。

9781484201589_Fig04-06.jpg

图 4-6 。在清单 4-7 中使用小于运算符时产生的输出

食谱 4-3。用逻辑运算符链接决策

问题

有时,为了将布尔值设置为 true,您的代码需要满足多个条件。

解决办法

C++ 提供了逻辑运算符 ,允许将关系语句链接起来。

它是如何工作的

C++ 提供了两个逻辑操作符,允许链接多个关系语句。这些是:

  • && (and)运算符
  • ||(或)运算符

&&运算符

当您想要确定两个不同的关系运算符都为真时,可以使用&&运算符 。清单 4-8 显示了正在使用的&操作符。

清单 4-8 。逻辑& &运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    bool isTrue { (10 == 10) && (12 == 12) };
    cout << "True? " << isTrue << endl;

    bool isFalse = isTrue && (1 == 2);
    cout << "True? " << isFalse << endl;

    return 0;
}

isTrue的值被设置为真,因为两个关系运算都产生真值。isFalse的值被设置为 false,因为两个关系语句都不会产生 true 语句。这些操作的输出可以在图 4-7 中看到。

9781484201589_Fig04-07.jpg

图 4-7 。&生成的逻辑&运算符输出由列表 4-8

逻辑||运算符

逻辑||运算符用于确定所用的一个或两个语句何时为真。清单 4-9 包含测试||操作符结果的代码。

清单 4-9 。逻辑||运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    bool isTrue { (1 == 1) || (0 == 1) };
    cout << "True? " << isTrue << endl;

    isTrue = (0 == 1) || (1 == 1);
    cout << "True? " << isTrue << endl;

    isTrue = (1 == 1) || (1 == 1);
    cout << "True? " << isTrue << endl;

    isTrue = (0 == 1) || (1 == 0);
    cout << "True? " << isTrue << endl;

    return 0;
}

该代码生成的结果输出可以在图 4-8 中看到。

9781484201589_Fig04-08.jpg

图 4-8 。使用逻辑||运算符时生成的输出

清单 4-9 证明了逻辑||操作符将会返回 true,只要其中一个或两个关系操作也为真。当两者都为 false 时||运算符也将返回 false。

Image 注意使用逻辑运算符时有一个常用的优化。一旦操作员满意,执行就会结束。这意味着当第一项为真时,||运算符不会计算第二项,当第一项为假时,& &运算符不会计算第二项。当在右侧语句中调用具有布尔返回值之外的次要效果的函数时,要小心这一点。

食谱 4-4。使用十六进制值

问题

您正在处理包含十六进制值的代码,您需要了解它们是如何工作的。

解决办法

C++ 允许在代码中使用十六进制值,程序员在写出数字的二进制表示时通常使用十六进制值。

它是如何工作的

计算机处理器使用二进制表示在存储器中存储数字,并使用二进制指令来测试和修改这些值。由于其低级本质,C++ 提供了位操作符,可以像处理器一样对变量中的位进行操作。一位信息可以是 1,也可以是 0。我们可以通过使用比特链来构造更高的数。一位可以代表数字 1 或 0。然而,两位可以代表 0、1、2 或 3。这是可以实现的,因为两位可以代表四个独特的信号;00,01,10 和 11。C++ int8_t 数据类型由 8 位组成。表 4-5 中的数据显示了这些不同的位是如何用数字表示的。

表 4-5 。一个 8 位变量中位的数值

Table4-5.jpg

存储由表 4-5 的表示的值的 uint8_t 变量将包含数字 137。事实上,一个 8 位变量可以存储 256 个单独的值。你可以计算出一个变量可以存储的值的数量,方法是将数字 2 提高到位数的幂,即 2⁸ 是 256。

Image 注意负数用有符号类型表示,使用与无符号类型相同的位数。在表 4-4 中,一个有符号的值将失去 128 的位置,成为一个符号位。您可以使用数字的二进制补码将正数转换为负数。要做到这一点,你翻转所有的位,并加上 1。对于两位数 1,你会有二进制表示 01。要得到二进制的补码,也就是负数,首先将这些位翻转为 10,然后加上 1,以 11 结尾。在一个 8 位的值中,你将遵循同样的过程。000000001 变成 11111110,加 1 得到 11111111。不管变量中的位数是多少,所有的位都打开时,-1 总是用二进制补码来表示,这是一个需要记住的有用事实。

在处理 16 位、32 位和 64 位数字时,完整地写出位很快就会失控。程序员倾向于用十六进制的格式来写二进制表示。十六进制数由值 0-9 和 A、B、C、D、E 和 F 表示。值 A-F 表示数字 10 到 15。用 4 位来表示 16 个十六进制值,因此我们现在可以用十六进制 0x89 来表示表 4-5 中的位模式,其中 9 代表低 4 位(8+1 是 9),8 代表高 4 位。

清单 4-10 展示了如何在代码中使用十六进制文字,并使用cout将它们打印到控制台。

清单 4-10 。使用十六进制文字值

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    uint32_t hexValue{ 0x89 };
    cout << "Decimal: " << hexValue << endl;
    cout << hex << "Hexadecimal: " << hexValue << endl;
    cout << showbase << hex << "Hexadecimal (with base): " << hexValue << endl;

    return 0;
}

C++ 中的十六进制文字以 0x 开头。这让编译器知道你打算用十六进制而不是十进制来解释这个数字。图 4-9 显示了与清单 4-10 中的 cout 一起使用的不同输出标志的效果。

9781484201589_Fig04-09.jpg

图 4-9 。打印出十六进制值

默认情况下,cout流打印整数变量的十进制表示。您必须将标志传递给cout来改变这种行为。hex标志通知cout它应该以十六进制打印数字,但是这不会自动加上 0x 基数。如果您希望您的输出以十六进制数为基础(您通常会这样做,这样其他用户就不会把值读成十进制 89 而不是 137),您可以使用showbase标志,它会让 cout 把 0x 加到您的十六进制值上。

清单 4-10 以 32 位整数类型存储 0x89 的值,但是表示仍然只有 8 位值。其他 6 位隐式为 0。137 的正确 32 位表示实际上应该是 0x00000089。

Image这在表示负数(如-1)时更为重要。使用 int32_t 时,0xF 表示 16 或 0x0000000F,其中-1 表示 0xFFFFFFFF。使用十六进制值时,请确保您设置的是真正想要的值。

配方 4-5。用二元运算符进行位旋转

问题

您正在开发一个应用程序,希望将数据打包成尽可能小的格式。

解决办法

您可以使用按位运算符来设置和测试变量上的单个位。

它是如何工作的

C++ 提供了以下按位运算符:

  • &(按位与)运算符
  • |(按位或)运算符
  • ^(异或)运算符
  • <
  • (右移)运算符

  • (补码)运算符

&(按位与)运算符

按位&运算符返回一个值,该值包含运算符左右两侧设置的所有位。清单 4-11 展示了一个这样的例子。

清单 4-11 。&运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    uint32_t bits{ 0x00011000 };
    cout << showbase << hex;
    cout << "Result of 0x00011000 & 0x00011000: "  << (bits & bits) << endl;
    cout << "Result of 0x00011000 & 0x11100111: "  << (bits & ~bits) << endl;

    return 0;
}

清单 4-11 同时使用了&~操作符。对&的频繁使用将导致数值 0x00011000 被输出到控制台。&的第二种用法是配合~使用。~运算符翻转所有位,因此使用&的输出将为 0。你可以在图 4-10 中看到这一点。

9781484201589_Fig04-10.jpg

图 4-10 。清单 4-11 中的输出结果

|(按位或)运算符

按位 or 运算符返回一个值,该值包含运算符左侧和右侧的所有设置位。无论设置了其中一个值还是两个值,都是如此。只有当操作符的左右两侧都没有设置该位置时,才会将 0 放置到位。清单 4-12 显示了使用中的|操作符。

清单 4-12

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    uint32_t leftBits{ 0x00011000 };
    uint32_t rightBits{ 0x00010100 };
    cout << showbase << hex;
    cout << "Result of 0x00011000 | 0x00010100: " << (leftBits | rightBits) << endl;
    cout << "Result of 0x00011000 & 0x11100111: " << (leftBits | ~leftBits) << endl;

    return 0;
}

第一次使用|将得到值 0x00011100,第二次使用将得到 0xFFFFFFFF。在图 4-11 中可以看到这是真的。

9781484201589_Fig04-11.jpg

图 4-11 。清单 4-12 生成的输出

存储在leftBitsrightBits中的值共享一个设置为 1 的位位置。有两个位置,一个位置有一个位设置,另一个没有。所有这三个位都设置在结果值中。第二种用法表明,只要位的位置设置在两个位置中的一个,所有的位都会被设置。当您查看下一个运算符的结果时,这两者之间的区别非常重要。

^(异或)运算符

该操作符的输出与图 4-11 所示的|操作符的输出之间会产生一个比特的差异。这是因为异或运算符仅在左位或右位被置位时将结果位设置为真,而不是在两者都被置位时,也不是在两者都没有被置位时。清单 4-12 中的第一个|操作符导致值 0x00011100 被存储为结果。使用相同的值时,^运算符将导致存储 0x00001100。清单 4-13 显示了这个场景的代码。

清单 4-13 。^算子

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    uint32_t leftBits{ 0x00011000 };
    uint32_t rightBits{ 0x00010100 };
    cout << showbase << hex;
    cout << "Result of 0x00011000 ^ 0x00010100: " << (leftBits ^ rightBits) << endl;
    cout << "Result of 0x00011000 ^ 0x11100111: " << (leftBits ^ ~leftBits) << endl;

    return 0;
}

产生不同输出的证据可以在图 4-12 中看到。

9781484201589_Fig04-12.jpg

图 4-12 。清单 4-13 中的^算子生成的输出

<< and >>操作员

左移和右移运算符是方便的工具,允许您将较小的数据集打包到较大的变量中。清单 4-14 显示了将一个值从uint32_t的低 16 位转移到高 16 位的代码。

清单 4-14 。使用< <运算符

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    const uint32_t maskBits{ 16 };
    uint32_t leftShifted{ 0x00001010 << maskBits };
    cout << showbase << hex;
    cout << "Left shifted: " << leftShifted << endl;

    return 0;
}

该代码导致值 0x10100000 存储在变量leftShifted中。这释放了较低的 16 位,现在可以用来存储另一个 16 位值。清单 4-15 使用了|=&操作符来完成这个任务。

Image 注意每个位运算符都有一个赋值变量,用于类似清单 4-15 中的语句。

清单 4-15 。使用掩码将值打包到变量中

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    const uint32_t maskBits{ 16 };
    uint32_t leftShifted{ 0x00001010 << maskBits };
    cout << showbase << hex;
    cout << "Left shifted: " << leftShifted << endl;

    uint32_t lowerMask{ 0x0000FFFF };
    leftShifted |= (0x11110110 & lowerMask);
    cout << "Packed left shifted: " << leftShifted << endl;

    return 0;
}

这段代码现在将两个独立的 16 位值打包到一个 32 位变量中。打包到低 16 位的值通过使用&运算符和屏蔽值(在本例中为 0x0000FFFF)屏蔽掉所有高 16 位*。这确保|=运算符保持高 16 位的值不变,因为被“或”运算的值不会设置任何高 16 位。你可以在图 4-13 中看到这一点。*

9781484201589_Fig04-13.jpg

图 4-13 。使用按位运算符将值屏蔽为整数的结果

图 4-13 中的最后两行输出是对变量上下部分的值进行去屏蔽操作的结果。你可以在清单 4-16 中看到这是如何实现的。

清单 4-16 。解除打包数据的屏蔽

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    const uint32_t maskBits{ 16 };
    uint32_t leftShifted{ 0x00001010 << maskBits };
    cout << showbase << hex;
    cout << "Left shifted: " << leftShifted << endl;

    uint32_t lowerMask{ 0x0000FFFF };
    leftShifted |= (0x11110110 & lowerMask);
    cout << "Packed left shifted: " << leftShifted << endl;

    uint32_t lowerValue{ (leftShifted & lowerMask) };
    cout << "Lower value unmasked: " << lowerValue << endl;

    uint32_t upperValue{ (leftShifted >> maskBits) };
    cout << "Upper value unmasked: " << upperValue << endl;

    return 0;
}

&运算符和>>运算符在清单 4-16 中用于从我们的打包变量中检索两个不同的值。不幸的是,这个代码有一个问题尚未被发现。清单 4-17 提供了这个问题的一个例子。

清单 4-17 。移位和收缩转换

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    const uint32_t maskBits{ 16 };
    uint32_t narrowingBits{ 0x00008000 << maskBits };

    return 0;
}

清单 4-17 中的代码将无法编译。您将收到一条错误消息,提示将要进行收缩转换,并且您的编译器将阻止您构建可执行文件,直到问题代码得到修复。这里的问题是,值 0x00008000 设置了第 16 个,一旦它向右移动 16 位,第 32 个将被设置。在正常情况下,这将导致该值变成负数。在这个阶段,你有两个不同的选择来应对这种情况。

Image 注意那些以前使用过 C++ 的人可能已经注意到,这些示例没有使用=操作符来初始化变量,例如uint32_t maskBits = 16;相反,我使用的是在 C++11 中引入的统一初始化。统一初始化是使用{}操作符的初始化形式,如下例所示。统一初始化的主要好处是防止我刚刚描述的收缩转换。

清单 4-18 展示了如何使用一个无符号文字来告诉编译器这个值应该是无符号的。

清单 4-18 。使用无符号文字

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    const uint32_t maskBits{ 16 };
    uint32_t leftShifted{ 0x00008080u << maskBits };
    cout << showbase << hex;
    cout << "Left shifted: " << leftShifted << endl;

    uint32_t lowerMask{ 0x0000FFFF };
    leftShifted |= (0x11110110 & lowerMask);
    cout << "Packed left shifted: " << leftShifted << endl;

    uint32_t lowerValue{ (leftShifted & lowerMask) };
    cout << "Lower value unmasked: " << lowerValue << endl;

    uint32_t upperValue{ (leftShifted >> maskBits) };
    cout << "Upper value unmasked: " << upperValue << endl;

    return 0;
}

u添加到数字文字的末尾会导致编译器将该文字作为无符号值进行评估。另一种选择是使用有符号的值。然而,这引入了一个新的考虑。当右移有符号值时,符号位被放入来自右边的新值中。可能会发生以下情况:

  • 0x10100000 >> 16 变成 0x00001010
  • 0x80800000 >> 16 变成 0xFFFF8080

清单 4-19 和图 4-14 显示了证明负号位传播的代码和输出。

清单 4-19 。负值右移

#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    const uint32_t maskBits{ 16 };
    int32_t leftShifted{ 0x00008080 << maskBits };
    cout << showbase << hex;
    cout << "Left shifted: " << leftShifted << endl;

    int32_t lowerMask{ 0x0000FFFF };
    leftShifted |= (0x11110110 & lowerMask);
    cout << "Packed left shifted: " << leftShifted << endl;

    int32_t rightShifted{ (leftShifted >> maskBits) };
    cout << "Right shifted: " << rightShifted << endl;
    cout << "Unmasked right shifted: " << (rightShifted & lowerMask) << endl;

    return 0;
}

在清单 4-19 的粗体行中,你可以看到新代码需要两个提取上部屏蔽值。当使用有符号整数时,单独的移位不再适用。图 4-14 显示了证明这一点的输出。

9781484201589_Fig04-14.jpg

图 4-14 。显示右移后符号位传播的输出

如您所见,我不得不将变量向右移动,屏蔽掉高位,以便从变量的高位检索原始值。在我们移位之后,该值包含十进制值-32,640 (0xFFFF8080),但是我们期望的值实际上是 32,896 (0x00008080)。通过使用&运算符(0x ffff 8080 | 0x 0000 ffff = 0x00008080)检索 0x 00008080。

五、类

类是将 C++ 与 C 编程语言区分开来的语言特性。向 C++ 中添加类允许它用于使用面向对象编程(OOP)范例设计的程序。OOP 很快成为世界范围内用于构建复杂应用程序的主要软件工程实践。您可以在当今大多数主流语言中找到类支持,包括 Java、C# 和 Objective-C。

配方 5-1。定义类别

问题

您的程序设计需要对象,您需要能够在程序中定义类。

解决办法

C++ 为创建类定义提供了class关键字和语法。

它是如何工作的

在 C++ 中,class关键字用于创建类定义。这个关键字后面是类名,然后是类的主体。清单 5-1 显示了一个类定义。

清单 5-1 。类别定义

class Vehicle
{

};

清单 5-1 中的Vehicle类定义告诉编译器它应该将单词Vehicle识别为类型。这意味着代码现在可以创建Vehicle类型的变量。清单 5-2 展示了这一点。

清单 5-2 。创建一个Vehicle变量

class Vehicle
{

};

int main(int argc, char* argv[])
{
    Vehicle myVehicle;
    return 0;
}

创建这样一个变量会导致你的程序创建一个对象。在处理类时使用的通用术语中,类定义本身被称为。该类的变量被称为对象,所以你可以拥有同一个类的多个对象。从一个类创建一个对象的过程被称为实例化一个类。

食谱 5-2。向类中添加数据

问题

您希望能够在您的类中存储数据。

解决办法

C++ 允许类包含变量。每个对象都有自己唯一的变量,并且可以存储自己的值。

它是如何工作的

C++ 有一个成员变量 的概念:一个存在于类定义中的变量。类定义中的每个实例化对象都有自己的变量副本。清单 5-3 显示了一个包含单个成员变量的类。

清单 5-3 。带有成员变量的Vehicle

#include <cinttypes>

class Vehicle
{
public:
    uint32_t m_NumberOfWheels;
};

Vehicle类包含一个单独的uint32_t变量来存储车辆的车轮数量。清单 5-4 展示了如何设置这个值并打印出来。

清单 5-4 。访问成员变量

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
public:
    uint32_t m_NumberOfWheels;
};

int main(int argc, char* argv[])
{
    Vehicle myCar;
    myCar.m_NumberOfWheels = 4;

    cout << "Number of wheels: " << myCar.m_NumberOfWheels << endl;

    return 0;
}

清单 5-4 展示了你可以使用点(.)操作符来访问一个对象上的成员变量。该操作符在代码中使用了两次:一次是将m_NumberOfWheels的值设置为 4,另一次是检索该值并打印出来。清单 5-5 添加了该类的另一个实例,以表明不同的对象可以在其成员中存储不同的值。

清单 5-5 。添加第二个对象

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
public:
    uint32_t m_NumberOfWheels;
};

int main(int argc, char* argv[])
{
    Vehicle myCar;
    myCar.m_NumberOfWheels = 4;

    cout << "Number of wheels: " << myCar.m_NumberOfWheels << endl;

    Vehicle myMotorcycle;
    myMotorcycle.m_NumberOfWheels = 2;

    cout << "Number of wheels: " << myMotorcycle.m_NumberOfWheels << endl;

    return 0;
}

清单 5-5 添加了第二个对象并命名为myMotorcycle。这个类的实例将其变量m_NumberOfWheels设置为 2。您可以在图 5-1 中看到不同的输出值。

9781484201589_Fig05-01.jpg

图 5-1 。清单 5-5 生成的输出

食谱 5-3。添加方法

问题

您需要能够在一个类上执行可重复的任务。

解决办法

C++ 允许程序员给类添加函数。这些函数被称为成员方法 ,可以访问类成员变量。

它是如何工作的

你可以简单地通过添加一个函数到一个类来添加一个成员方法。您添加的任何函数都可以使用属于该类的成员变量。清单 5-6 展示了两个成员方法。

清单 5-6 。向类中添加成员方法

#include <cinttypes>
class Vehicle
{
public:
    uint32_t m_NumberOfWheels;

    void SetNumberOfWheels(uint32_t numberOfWheels)
    {
        m_NumberOfWheels = numberOfWheels;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

清单 5-6 中的所示的Vehicle类包含两个成员方法:SetNumberOfWheels获取一个用于设置成员m_NumberOfWheels的参数,GetNumberOfWheels获取m_NumberOfWheels的值。清单 5-7 使用了这些方法。

清单 5-7 。使用来自Vehicle类的成员方法

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    uint32_t m_NumberOfWheels;

public:
    void SetNumberOfWheels(uint32_t numberOfWheels)
    {
        m_NumberOfWheels = numberOfWheels;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar;
    myCar.SetNumberOfWheels(4);

    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle;
    myMotorcycle.SetNumberOfWheels(2);

    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    return 0;
}

成员方法用于改变和检索清单 5-7 中成员变量的值。该代码生成的输出如图 5-2 中的所示。

9781484201589_Fig05-02.jpg

图 5-2 。清单 5-7 中的代码生成的输出

食谱 5-4。使用访问修饰符

问题

将所有成员变量暴露给调用代码会导致几个问题,包括高耦合性和更高的维护成本。

解决办法

使用 C++ 访问修饰符来利用封装并隐藏调用代码的类实现。

它是如何工作的

C++ 提供了访问修饰符,允许您控制代码是否可以访问内部成员变量和方法。清单 5-8 展示了如何使用private访问修饰符来限制对变量的访问,以及如何使用public访问说明符来提供间接访问成员的方法。

清单 5-8 。使用publicprivate访问修饰符

#include <cinttypes>

class Vehicle
{
private:
    uint32_t m_NumberOfWheels;

public:
    void SetNumberOfWheels(uint32_t numberOfWheels)
    {
        m_NumberOfWheels = numberOfWheels;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

要使用访问修饰符,请在类中插入关键字,后跟一个冒号。一旦被调用,访问修饰符将应用于所有成员变量和随后的方法,直到指定另一个访问修饰符。在清单 5-8 的中,这意味着m_NumberOfWheels变量是私有的,而SetNumberOfWheelsGetNumberOfWheels成员方法是公共的。

如果你试图在调用代码中直接访问m_NumberOfWheels,你的编译器会给你一个访问错误。相反,您必须通过成员方法来访问变量。清单 5-9 展示了一个带有私有成员变量的工作示例。

清单 5-9 。使用访问修饰符

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    uint32_t m_NumberOfWheels;

public:
    void SetNumberOfWheels(uint32_t numberOfWheels)
    {
        m_NumberOfWheels = numberOfWheels;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar;
    // myCar.m_NumberOfWheels = 4; -Access error
    myCar.SetNumberOfWheels(4);

    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle;
    myMotorcycle.SetNumberOfWheels(2);

    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    return 0;
}

通过取消清单 5-9 中粗体行的注释,您可以看到编译器生成的错误。以这种方式封装数据允许您在以后更改实现,而不会影响代码的其余部分。清单 5-10 更新了来自清单 5-9 的代码,使用一种完全不同的方法来计算车辆的车轮数量。

清单 5-10 。改变Vehicle类的实现

#include <vector>
#include <cinttypes>
#include <iostream>

using namespace std;

class Wheel
{

};

class Vehicle
{
private:
    using Wheels = vector<Wheel>;
    Wheels m_Wheels;

public:
    void SetNumberOfWheels(uint32_t numberOfWheels)
    {
        m_Wheels.clear();
        for (uint32_t i = 0; i < numberOfWheels; ++i)
        {
            m_Wheels.push_back({});
        }
    }

    uint32_t GetNumberOfWheels()
    {
        return m_Wheels.size();
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar;
    myCar.SetNumberOfWheels(4);

    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle;
    myMotorcycle.SetNumberOfWheels(2);

    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    return 0;
}

比较清单 5-9 中的Vehicle类和清单 5-10 中的Vehicle类,可以发现SetNumberOfWheelsGetNumberOfWheels的实现完全不同。清单 5-10 中的类不将值存储在uint32_t成员中;相反,它存储了一个Wheel对象的vector。对于作为其numberOfWheels参数提供的数字,SetNumberOfWheels方法向vector添加一个新的Wheel实例。GetNumberOfWheels方法返回vector的大小。两个清单中的 main 函数是相同的,执行代码生成的输出也是如此。

食谱 5-5。初始化类成员变量

问题

未初始化的变量会导致未定义的程序行为。

解决办法

C++ 类可以在实例化时初始化其成员变量,并为用户提供的值提供构造函数方法。

它是如何工作的

统一初始化

C++ 中的类可以使用统一初始化在实例化时为类成员提供默认值。统一初始化允许您在初始化从类创建的内置类型或对象时使用通用语法。C++ 使用花括号语法来支持这种形式的初始化。清单 5-11 展示了一个类,它的成员变量以这种方式初始化。

清单 5-11 。初始化类成员变量

#include <cinttypes>
class Vehicle
{
private:
    uint32_t m_NumberOfWheels{};

public:
    uint32 GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

在清单 5-11 的中,类的m_NumberOfWheels成员使用统一初始化进行初始化。这是通过在名称后使用花括号来实现的。没有向初始值设定项提供值,这导致编译器将值初始化为 0。清单 5-12 展示了这个类在上下文中的使用。

清单 5-12 。使用Vehicle

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    uint32_t m_NumberOfWheels{};

public:
    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar;
    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle;
    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    return 0;
}

图 5-3 显示了该代码生成的输出。

9781484201589_Fig05-03.jpg

图 5-3 。由清单 5-12 中的代码生成的输出。

图 5-3 显示了每个等级的输出为 0。这是对不初始化数据的代码的改进,如图图 5-4 所示。

9781484201589_Fig05-04.jpg

图 5-4 。不初始化成员变量的程序产生的输出

使用构造函数

图 5-3 代表比图 5-4 更好的情况,但两者都不理想。您真的希望清单 5-12 中的myCarmyMotorcycle对象打印不同的值。清单 5-13 添加了一个构造函数,这样你就可以在实例化类时指定轮子的数量。

清单 5-13 。向类中添加构造函数

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    uint32_t m_NumberOfWheels{};

public:
    Vehicle(uint32_t numberOfWheels)
        : m_NumberOfWheels{ numberOfWheels }
    {

    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar{ 4 };
    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle{ 2 };
    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    return 0;
}

清单 5-13 增加了在实例化时初始化Vehicle上轮子数量的能力。它通过向Vehicle类添加一个构造函数来实现这一点,该构造函数将车轮数量作为参数。使用构造函数可以让您依赖于在对象创建时发生的函数调用。该函数用于确保类中包含的所有成员变量都已正确初始化。未初始化的数据是导致意外程序行为(如崩溃)的一个非常常见的原因。

myCarmyMotorcycle对象使用不同的轮子数量值进行实例化。不幸的是,向该类添加构造函数意味着您不能再构造该类的默认版本;你必须始终在清单 5-13 中提供一个车轮数量值。清单 5-14 通过在类中添加一个显式的默认操作符来克服这个限制。

清单 5-14 。默认构造函数

#include <cinttypes>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(uint32_t numberOfWheels)
        : m_NumberOfWheels{ numberOfWheels }
    {

    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar{ 4 };
    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle{ 2 };
    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    Vehicle noWheels;
    cout << "Number of wheels: " << noWheels.GetNumberOfWheels() << endl;

    return 0;
}

清单 5-14 中的Vehicle类包含一个显式的default构造函数。default关键字和equals操作符一起使用,通知编译器你想给这个类添加一个default构造函数。由于m_NumberOfWheels变量的统一初始化,您可以创建一个在m_NumberOfWheels变量中包含 0 的类noWheels的实例。图 5-5 显示了该代码生成的输出。

9781484201589_Fig05-05.jpg

图 5-5 。清单 5-14 生成的输出,显示了noWheels类中的 0

配方 5-6。清理班级

问题

当一个对象被销毁时,一些类需要清理它们的成员。

解决办法

C++ 允许将析构函数添加到类中,当类被销毁时,允许代码被执行。

它是如何工作的

您可以使用~语法向 C++ 中的类添加一个特殊的析构函数方法。清单 5-15 展示了如何实现这一点。

清单 5-15 。向类中添加析构函数

#include <cinttypes>
#include <string>

using namespace std;

class Vehicle
{
private:
    string m_Name;
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(string name, uint32_t numberOfWheels)
        : m_Name{ name }
        , m_NumberOfWheels{ numberOfWheels }
    {

    }

    ~Vehicle()
    {
        cout << m_Name << " is being destroyed!" << endl;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

清单 5-15 中的类包含一个析构函数。这个析构函数只是打印出被销毁对象的名字。构造函数可以用对象名初始化,Vehicle的默认构造函数自动调用string类的默认构造函数。清单 5-16 展示了如何在实践中使用这个类。

清单 5-16 。使用带有析构函数的类

#include <cinttypes>
#include <iostream>
#include <string>

using namespace std;

class Vehicle
{
private:
    string m_Name;
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(string name, uint32_t numberOfWheels)
        : m_Name{ name }
        , m_NumberOfWheels{ numberOfWheels }
    {

    }

    ~Vehicle()
    {
        cout << m_Name << " is being destroyed!" << endl;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar{ "myCar", 4 };
    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle{ "myMotorcycle", 2 };
    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    Vehicle noWheels;
    cout << "Number of wheels: " << noWheels.GetNumberOfWheels() << endl;

    return 0;
}

从清单 5-16 中的 main 函数可以看出,你不需要添加任何特殊的代码来调用一个类析构函数。当对象超出范围时,析构函数被自动调用。在这种情况下,对Vehicle对象的析构函数的调用发生在return之后。图 5-6 显示了这个程序的输出,证明析构函数代码被执行。

9781484201589_Fig05-06.jpg

图 5-6 。清单 5-16 生成的输出,显示析构函数已经被执行

注意这些析构函数的调用顺序很重要。对象被销毁的顺序与它们被创建的顺序相反。如果您的资源依赖于以正确的顺序创建和销毁,这一点很重要。

如果您没有定义自己的析构函数,编译器会隐式创建一个默认的析构函数。你也可以使用清单 5-17 中的代码显式定义一个析构函数。

清单 5-17 。显式定义析构函数

#include <cinttypes>

class Vehicle
{
private:
        uint32_t m_NumberOfWheels{};

public:
        Vehicle() = default;

        Vehicle(uint32_t numberOfWheels)
                : m_NumberOfWheels{ numberOfWheels }
        {

        }

        ~Vehicle() = default;

        uint32_t GetNumberOfWheels()
        {
                return m_NumberOfWheels;
        }
};

始终明确默认的构造函数和析构函数被认为是一种好的做法。这样做可以消除代码中的任何歧义,并让其他程序员知道您对默认行为感到满意。这段代码的省略可能会导致其他人认为您忽略了它的包含。

食谱 5-7。复制类

问题

您希望确保以正确的方式将数据从一个对象复制到另一个对象。

解决办法

C++ 提供了复制构造函数和赋值操作符,您可以使用它们将代码添加到您的类中,在复制发生时执行这些代码。

它是如何工作的

您可以在许多情况下复制 C++ 中的对象。当您将一个对象传递给同类型的另一个对象的构造函数时,它就被复制了。当您将一个对象分配给另一个对象时,也会复制一个对象。通过值将对象传递给函数或方法也会导致复制操作的发生。

隐式和默认复制构造函数和赋值运算符

C++ 类通过复制构造函数和赋值操作符支持这些操作。清单 5-18 显示了在 main 方法中调用的这些方法的默认版本。

清单 5-18 。使用复制构造函数和赋值运算符

#include <cinttypes>
#include <iostream>
#include <string>

using namespace std;

class Vehicle
{
private:
    string m_Name;
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(string name, uint32_t numberOfWheels)
        : m_Name{ name }
        , m_NumberOfWheels{ numberOfWheels }
    {

    }

    ~Vehicle()
    {
        cout << m_Name << " at " << this << " is being destroyed!" << endl;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar{ "myCar", 4 };
    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle{ "myMotorcycle", 2 };
    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    Vehicle myCopiedCar{ myCar };
    cout << "Number of wheels: " << myCopiedCar.GetNumberOfWheels() << endl;

    Vehicle mySecondCopy;
    mySecondCopy = myCopiedCar;
    cout << "Number of wheels: " << mySecondCopy.GetNumberOfWheels() << endl;

    return 0;
}

使用复制构造函数来构造myCopiedCar变量。这是通过将另一个相同类型的对象传递到myCopiedCar的大括号初始化器中来实现的。mySecondCopy变量是使用默认的构造函数构造的。因此,该对象用一个空名称和 0 作为轮子的数量进行初始化。然后代码使用myCopiedCar将值分配给mySecondCopy。你可以在图 5-7 中看到这些操作的结果。

9781484201589_Fig05-07.jpg

图 5-7 。清单 5-18 生成的输出

正如所料,您有三个名为myCar的对象,每个对象都有四个轮子。当析构函数输出每个对象在内存中的地址时,你可以看到不同的对象。

显式复制构造函数和赋值运算符

清单 5-18 中的代码利用了隐式复制构造函数和赋值操作符。当 C++ 编译器遇到使用这些函数的代码时,它会自动将这些函数添加到您的类中。清单 5-19 展示了如何显式地创建这些函数。

清单 5-19 。显式创建复制构造函数和赋值运算符

#include <cinttypes>
#include <iostream>
#include <string>

using namespace std;

class Vehicle
{
private:
    string m_Name;
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(string name, uint32_t numberOfWheels)
        : m_Name{ name }
        , m_NumberOfWheels{ numberOfWheels }
    {

    }

    ~Vehicle()
    {
        cout << m_Name << " at " << this << " is being destroyed!" << endl;
    }

    Vehicle(const Vehicle& **other) = default;**
    **Vehicle**`&` **operator=(const Vehicle**`&` **other) = default;**

    `uint32_t GetNumberOfWheels()`
    `{`
        `return m_NumberOfWheels;`
    `}`
`};`

复制构造函数的签名类似于普通构造函数的签名。这是一个没有返回类型的方法;但是,复制构造函数将对同一类型对象的常量引用作为参数。当语句的右边是同类型的另一个对象时,赋值操作符使用操作符重载来重载该类的=算术操作符,如在someVehicle = someOtherVehicle中。default`关键字再次变得有用,它允许你与其他程序员交流,你对默认操作感到满意。

不允许复制和转让

有时你会创建一些你绝对不希望使用复制构造函数和赋值操作符的类。C++ 为这些情况提供了delete关键字。清单 5-20 展示了这是如何实现的。

清单 5-20 。不允许复制和转让

#include <cinttypes>
#include <iostream>
#include <string>

using namespace std;

class Vehicle
{
private:
    string m_Name;
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(string name, uint32_t numberOfWheels)
        : m_Name{ name }
        , m_NumberOfWheels{ numberOfWheels }
    {

    }

    ~Vehicle()
    {
        cout << m_Name << " at " << this << " is being destroyed!" << endl;
    }

    Vehicle(const Vehicle& other) = delete;
    Vehicle& operator=(const Vehicle& other) = delete;

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myCar{ "myCar", 4 };
    cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;

    Vehicle myMotorcycle{ "myMotorcycle", 2 };
    cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;

    Vehicle myCopiedCar{ myCar };
    cout << "Number of wheels: " << myCopiedCar.GetNumberOfWheels() << endl;

    Vehicle mySecondCopy;
    mySecondCopy = myCopiedCar;
    cout << "Number of wheels: " << mySecondCopy.GetNumberOfWheels() << endl;

    return 0;
}

delete关键字用来代替default来通知编译器你不希望复制和赋值操作对一个类可用。main 函数中的代码将不再编译和运行。

自定义复制构造函数和赋值运算符

除了使用这些操作的默认版本,还可以提供您自己的版本。这是通过对类定义中的方法使用相同的签名,但提供一个方法体来代替默认赋值来实现的。

在现代 C++ 中,你重载这些操作符的地方往往是有限的;但是重要的是要知道你绝对想这么做的地方。默认的复制和赋值操作执行浅复制 。它们在对象的每个成员上调用赋值操作符,并从传入的类中赋值。有些情况下,您有一个手动管理资源(如内存)的类,而一个浅表副本在两个类中都有一个指向内存中相同地址的指针。如果内存是在类的析构函数中释放的,那么就会出现一个对象指向另一个对象释放的内存的情况。在这种情况下,您的程序很可能会崩溃或表现出其他奇怪的行为。清单 5-21 显示了一个可能发生这种情况的例子。

清单 5-21 。浅复制 C 样式的字符串成员

#include <cinttypes>
#include <cstring>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    char* m_Name{};
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(const char* name, uint32_t numberOfWheels)
        : m_NumberOfWheels{ numberOfWheels }
    {
        const uint32_t length = strlen(name) + 1; // Add space for null terminator
        m_Name = new char[length]{};
        strcpy(m_Name, name);
    }

    ~Vehicle()
    {
        delete m_Name;
        m_Name = nullptr;
    }

    Vehicle(const Vehicle& other) = default;
    Vehicle& operator=(const Vehicle& other) = default;

    char* GetName()
    {
        return m_Name;
    }

    uint32_t GetNumberOfWheels()
    {
        return m_NumberOfWheels;
    }
};

int main(int argc, char* argv[])
{
    Vehicle myAssignedCar;

    {
        Vehicle myCar{ "myCar", 4 };
        cout << "Vehicle name: " << myCar.GetName() << endl;

        myAssignedCar = myCar;
        cout << "Vehicle name: " << myAssignedCar.GetName() << endl;
    }

    cout << "Vehicle name: " << myAssignedCar.GetName() << endl;

    return 0;
}

Image 注意清单 5-21 中的代码是有目的地构建的,以创建一个使用 STL 字符串类可以更好解决的情况。这段代码只是一个简单易懂的例子,说明事情是如何出错的。

清单 5-21 中的 main 函数创建了Vehicle类的两个实例。第二个是在块中创建的。当块结束并且对象超出范围时,这个块导致myCar对象被析构。这是一个问题,因为代码块的最后一行调用了赋值操作符,并对类成员进行了浅层复制。在这发生之后,myCarmyAssignedCar对象在它们的m_Name变量中指向相同的内存地址。在代码试图打印出myAssignedCar的名字之前,这个内存在myCar的析构函数中被释放。你可以在图 5-8 中看到这个错误的结果。

9781484201589_Fig05-08.jpg

图 5-8 。显示在销毁对象之前浅复制对象的错误的输出

图 5-8 证明了浅拷贝导致代码处于危险境地。一旦myCar变量被销毁,由myAssignedCar中的m_Name变量指向的内存就不再有效。清单 5-22 通过提供一个复制构造函数和一个赋值操作符来实现类的深度复制,解决了这个问题。

清单 5-22 。执行深度复制

#include <cinttypes>
#include <cstring>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    char* m_Name{};
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(const char* name, uint32_t numberOfWheels)
        : m_NumberOfWheels{ numberOfWheels }
    {
        const uint32_t length = strlen(name) + 1; // Add space for null terminator
        m_Name = new char[length]{};
        strcpy(m_Name, name);
    }

    ~Vehicle()
    {
        delete m_Name;
        m_Name = nullptr;
    }

    Vehicle(const Vehicle& **other)**
    **{**
        **const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator**
        **m_Name = new char[length]{};**
        **strcpy(m_Name, other.m_Name);**

        **m_NumberOfWheels = other.m_NumberOfWheels;**
    **}**

    **Vehicle&** `**operator=(const Vehicle&** `**other)**
    **{**
        **if (m_Name != nullptr)**
        **{**
            **delete m_Name;**
        **}**

        **const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator**
        **m_Name = new char[length]{};**
        **strcpy(m_Name, other.m_Name);**

        **m_NumberOfWheels = other.m_NumberOfWheels;**

        **return *this;**
    **}**

    `char* GetName()`
    `{`
        `return m_Name;`
    `}`

    `uint32_t GetNumberOfWheels()`
    `{`
        `return m_NumberOfWheels;`
    `}`
`};`

`int main(int argc, char* argv[])`
`{`
    `Vehicle myAssignedCar;`

    `{`
        `Vehicle myCar{ "myCar", 4 };`
        `cout << "Vehicle name: " << myCar.GetName() << endl;`

        `myAssignedCar = myCar;`
        `cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`
    `}`

    `cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`

    `return 0;`
`}```
```cpp

 ``这一次,代码提供了在发生复制或赋值时要执行的方法。当通过复制旧对象来创建新对象时,会调用复制构造函数,因此您永远不需要担心删除旧数据。另一方面,赋值操作符不能保证现有的类不存在。当赋值操作符负责任地删除为现有的`m_Name`变量分配的内存时,您可以看到这一点的含义。这些深度复制的结果可以在图 5-9 中看到。

![9781484201589_Fig05-09.jpg](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/f63577f8724d4567b4e1e6303b2cb3c7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770619974&x-signature=RRA2m%2BNfABn0rrxJawHR4q95jjc%3D)5-9 。使用深层副本的结果

由于使用了深层拷贝,现在输出是正确的。这给了`myAssignedCar`变量它自己的`name`字符串的副本,而不是简单地给它的指针分配与`myCar`类相同的地址。在这种情况下,解决问题的正确方法是使用 STL 字符串来代替 C 风格的字符串,但是如果您将来不得不编写可能指向相同的动态分配内存或堆栈内存的类,那么这个示例将是有效的。

食谱 5-8。用移动语义优化代码

问题

您的代码运行缓慢,您认为问题是由复制临时对象引起的。

解决办法

C++ 以移动构造函数和移动赋值操作符的形式提供了对移动语义的支持。

它是如何工作的

清单 5-23 中显示的代码执行一个对象的深度复制,以避免不同的对象指向一个无效的内存地址。

***清单 5-23*** 。使用深度拷贝避免无效指针

#include #include #include

using namespace std;

class Vehicle { private:     char* m_Name{};     uint32_t m_NumberOfWheels{};

public:     Vehicle() = default;

    Vehicle(const char* name, uint32_t numberOfWheels)         : m_NumberOfWheels{ numberOfWheels }     {         const uint32_t length = strlen(name) + 1; // Add space for null terminator         m_Name = new char[length]{};         strcpy(m_Name, name);     }

    ~Vehicle()     {         delete m_Name;         m_Name = nullptr;     }

    Vehicle(const Vehicle& other)     {         const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator         m_Name = new char[length]{};         strcpy(m_Name, other.m_Name);

        m_NumberOfWheels = other.m_NumberOfWheels;     }

    Vehicle& **operator=(const Vehicle&** other)     {         if (m_Name != nullptr)         {             delete m_Name;         }

        const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator         m_Name = new char[length]{};         strcpy(m_Name, other.m_Name);

        m_NumberOfWheels = other.m_NumberOfWheels;

        *return this;     }

    char* GetName()     {         return m_Name;     }

    uint32_t GetNumberOfWheels()     {         return m_NumberOfWheels;     } };

int main(int argc, char* argv[]) {     Vehicle myAssignedCar;

    {         Vehicle myCar{ "myCar", 4 };         cout << "Vehicle name: " << myCar.GetName() << endl;

        myAssignedCar = myCar;         cout << "Vehicle name: " << myAssignedCar.GetName() << endl;     }

    cout << "Vehicle name: " << myAssignedCar.GetName() << endl;

    return 0; `}```cpp


 ``当您知道两个对象可能存在相当长的时间,但其中一个可能在另一个之前被销毁,这可能会导致崩溃时,这是正确的解决方案。然而,有时你知道你复制的对象将要被销毁。C++ 允许你使用移动语义来优化这种情况。清单 5-24 给类添加了一个移动构造函数和一个移动赋值操作符,并使用`move`函数来调用它们。

***清单 5-24*** 。移动构造函数和移动赋值运算符

```cpp
#include <cinttypes>
#include <cstring>
#include <iostream>

using namespace std;

class Vehicle
{
private:
    char* m_Name{};
    uint32_t m_NumberOfWheels{};

public:
    Vehicle() = default;

    Vehicle(const char* name, uint32_t numberOfWheels)
        : m_NumberOfWheels{ numberOfWheels }
    {
        const uint32_t length = strlen(name) + 1; // Add space for null terminator
        m_Name = new char[length]{};
        strcpy(m_Name, name);
    }

    ~Vehicle()
    {
        if (m_Name != nullptr)
        {
            delete m_Name;
            m_Name = nullptr;
        }
    }

    Vehicle(const Vehicle& other)
    {
        const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator
        m_Name = new char[length]{};
        strcpy(m_Name, other.m_Name);

        m_NumberOfWheels = other.m_NumberOfWheels;
    }

    Vehicle& operator=(const Vehicle& other)
    {
        if (m_Name != nullptr)
        {
            delete m_Name;
        }

        const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator
        m_Name = new char[length]{};
        strcpy(m_Name, other.m_Name);

        m_NumberOfWheels = other.m_NumberOfWheels;

        return *this;
    }

    Vehicle(Vehicle&& **other)**
    **{**
        **m_Name = other.m_Name;**
        **other.m_Name = nullptr;**

        **m_NumberOfWheels = other.m_NumberOfWheels;**
    **}**

    **Vehicle&** `**operator=(Vehicle&&** `**other)**
    **{**
        **if (m_Name != nullptr)**
        **{**
            **delete m_Name;**
        **}**

        **m_Name = other.m_Name;**
        **other.m_Name = nullptr;**

        **m_NumberOfWheels = other.m_NumberOfWheels;**

        **return *this;**
    **}**

    `char* GetName()`
    `{`
        `return m_Name;`
    `}`

    `uint32_t GetNumberOfWheels()`
    `{`
        `return m_NumberOfWheels;`
    `}`
`};`

`int main(int argc, char* argv[])`
`{`
    `Vehicle myAssignedCar;`

    `{`
        `Vehicle myCar{ "myCar", 4 };`
        `cout << "Vehicle name: " << myCar.GetName() << endl;`

        **myAssignedCar = move(myCar);**
        `//cout << "Vehicle name: " << myCar.GetName() << endl;`
        `cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`
    `}`

    `cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`

    `return 0;`
`}```
```cpp

 ``Move 语义通过提供将`rvalue`引用作为参数的类方法来工作。这些`rvalue`引用通过在参数类型上使用双&运算符来表示。您可以使用`move`功能调用移动操作;您可以在`main`函数中看到这一点。这里可以使用`move`函数,因为你知道`myCar`即将被销毁。调用移动赋值操作符,指针地址被浅拷贝到`myAssignedCar`。移动赋值操作符释放对象可能已经使用了`m_Name`的内存。重要的是,在将`other.m_Name`设置为`nullptr`之前,它会从`other`复制地址。将`other`对象的指针设置为`nullptr`可以防止该对象删除其析构函数中的内存。在这种情况下,代码能够将`m_Name`的值从`other`移动到`this`,而不必分配更多的内存并将值从一个深度复制到另一个。最终的结果是你不能再使用由`myCar`存储的`m_Name`的值——清单 5-24 的`main`函数中被注释掉的行将导致崩溃。```````