【C++】C++基础记录(三)

【C++】C++基础记录(三)

本文记录了我学习C++的一些基础条目知识

本文是C++扫盲的第三篇记录,从STL位标志往后的一些进阶内容。

前面的相关文章:

C++基础记录

C++基础记录(二)

【算法刷题】C++常见容器使用集合

STL位标志

bitset

std::bitset 是 C++ 标准库中一个非常实用的类,它提供了一种管理和操作固定大小的位的集合(bit set)的方式。你可以把它想象成一个数组,但这个数组的每个元素都只能是 0 或 1。

从低位到高位是从右往左数,第 0 位是最右边的位。

主要特点:

  • 固定大小: std::bitset 的大小在编译时就确定了。你需要用一个模板参数来指定它能存储多少位,例如 std::bitset<32> 表示一个能存储 32 位的 bit set。
  • 高效: 因为大小是固定的,它在内存中通常会以一个或多个无符号整数的形式存储,这使得位操作(如位移、按位与、或、异或等)非常高效。
  • 方便的接口: 它提供了很多方便的方法来操作和查询位,比如设置、清零、翻转、测试特定位等。

实例化 std::bitset

实例化这个模板类时,必须通过一个模板参数指定实例需要管理的位数:

bitset <4> fourBits; // 4 bits initialized to 0000

还可将 bitset 初始化为一个用字符串字面量(char*)表示的位序列:

bitset <5> fiveBits("10101"); // 5 bits 10101

使用一个 bitset 来实例化另一个 bitset 非常简单:

bitset <8> fiveBitsCopy(fiveBits); 

使用实例:

#include <iostream>
#include <bitset>
using namespace std;
int main() {
  // 创建一个 4 位的 bitset
  bitset<4> fourBits; // 4 bits initialized to 0000
  // 创建一个 5 位的 bitset
  bitset<5> fiveBits("10101"); // 5 bits 10101
  // 创建一个 8 位的 bitset
  bitset<8> eightBits(255); // 8 bits 11111111
  // 创建一个 8 位的 bitset 副本
  bitset <8> eightBitsCopy(eightBits); 
  
  return 0;
}

bitset运算符

std::bitset 提供了多种运算符来进行位操作,这些运算符使得对位集合的操作变得非常直观和高效。以下是一些常用的运算符:

运算符描述
«将位序列的文本表示插入到输出流中
»将一个字符串插入到bitset对象中
~按位取反
&按位与
|按位或
^按位异或
«=左移,例如左移两位 fourBits «= 2;
»=右移
[]访问特定位

使用举例:

#include <iostream>
#include <bitset>

using namespace std;

int main() {
    bitset<8> b1(1);
    // 00000001
    b1 <<= 2;
    // 左移两位,变为 00000100
    cout << "b1 after left shift by 2: " << b1 << endl;
    return 0;
}

取反:

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

int main() {
    bitset<8> b1(5); // 00000101
    cout << "b1: " << b1 << endl;
    cout << "b1 after NOT: " << ~b1 << endl; // 11111010
    return 0;
}

bitset成员方法

| 函数 | 描述 | | — | — | | set() | 将序列中的所有位都设置为 1 | | set (N, val=1) | 将第 N+1 位设置为 val 指定的值(默认为 1) | | reset() | 将序列中的所有位都重置为 0 | | reset (N) | 将偏移位置为(N+1)的位清除 | | flip() | 将位序列中的所有位取反 | | size() | 返回序列中的位数 | | count() | 返回序列中值为 1 的位数 |

使用举例:

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

int main() {
    bitset<8> b1(5); // 00000101
    cout << "b1: " << b1 << endl;
    b1.set(2); // 将第 3 位设置为 1
    cout << "b1 after set(2): " << b1 << endl;
    b1.reset(2); // 将第 3 位设置为 0
    cout << "b1 after reset(2): " << b1 << endl;
    b1.flip(); // 取反
    cout << "b1 after flip(): " << b1 << endl;
    return 0;
}

vector<bool>

STL bitset 的缺点之一是不能动态地调整长度。仅当在编辑阶段知道序列将存储多少位时才能使用 bitset。 为了克服这种缺点,STL 向程序员提供了 vector<bool> 类(在有些 STL 实现中为bit_vector)。

实例化 vector<bool> 的方式与实例化 vector 类似,有一些方便的重载构造函数可供使用:

vector <bool> boolFlags1;

例如,可创建一个这样的 vector,即它最初包含 10 个布尔元素,且每个元素都被初始化为 1(即true):

vector <bool> boolFlags2 (10, true);

还可使用一个 vector<bool> 创建另一个 vector<bool>

vector <bool> boolFlags2Copy (boolFlags2); 

vector<bool> 成员方法和运算符

vector<bool>提供了函数 flip(),用于将序列中的布尔值取反,这与函数 bitset<>::flip() 很像。

除这个方法外,vector<bool>std::vector 极其相似,例如,可使用 push_back 将标志位插入到序列中。

使用举例:

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

int main() {
    vector<bool> vb(3);
    vb[0] = true;
    vb[1] = false;
    vb[2] = true;
    vb.push_back(true);
    
    for (int i = 0; i < vb.size(); i++)
    {
        cout << vb[i] << endl;
    }

    vb.flip();
    cout << "After flip:" << endl;
    for (int i = 0; i < vb.size(); i++)
    {
        cout << vb[i] << endl;
    }

    return 0;
}

一开始写作了:

vector<bool> vb;
vb[0] = true;

没有指定大小,就直接使用 0 位,严重错误,直接越界。 需要养成声明即初始化的好习惯 ,使用前确定大小。

可以先初始化大小,或者先push_back添加元素,再修改对应位置。也可以使用初始化列表: vector <bool> boolFlags{ true, true, false } .

智能指针

C++在内存分配、释放和管理方面具有其他语言不具备的灵活性。同时这种松散的机制也会引发一些不确定性。

例如:

SomeClass* ptrData = anObject.GetData ();
/*
 Questions: Is object pointed by ptrData dynamically allocated using new?
 If so, who calls delete? Caller or the called?
 Answer: No idea!
*/
ptrData->DoSomething();

在上述代码中,没有显而易见的方法获悉 ptrData 指向的内存:

  • 是否是从堆中分配的,因此最终需要释放;
  • 是否由调用者负责释放;
  • 对象的析构函数是否会自动销毁该对象。

优势

智能指针可以自动管理动态分配的内存,避免内存泄漏和悬空指针等问题。

smart_pointer<SomeClass> spData = anObject.GetData ();
// Use a smart pointer like a conventional pointer!
spData->Display ();
(*spData).Display ();
// Don't have to worry about de-allocation
// (the smart pointer's destructor does it for you)

智能指针的行为类似常规指针(这里将其称为原始指针),但通过重载的运算符和析构函数确保动态分配的数据能够及时地销毁,从而提供了更多有用的功能。

智能指针类重载了 解除引用运算符(*)成员选择运算符(->) ,让程序员可以像使用常规指针那样使用智能指针。

一个简单的智能指针类

代码如下:

template <typename T>
class smart_pointer
{
  private:
    T* ptr;
  public:
    smart_pointer(T* p = nullptr) : ptr(p) {}
    ~smart_pointer() { delete ptr; }
    T& operator*() { return *ptr; }
    T* operator->() { return ptr; }
    // copy constructor
    smart_pointer(const smart_pointer& sp) : ptr(sp.ptr) {}
    // assignment operator
    smart_pointer& operator=(const smart_pointer& sp)
    {
        if (this != &sp)
        {
            delete ptr;
            ptr = sp.ptr;
        }
        return *this;
    }
};

实现了 *-> 运算符,从而可以像常规意义上的指针那样使用它。

插入:内存管理策略

使智能指针真正“智能”的是复制构造函数、赋值运算符和析构函数的实现,它们决定了智能指针对象被传递给函数、赋值或离开作用域(即像其他类对象一样被销毁)时的行为。

策略:深复制

回顾一下切除问题:

void MakeFishSwim(Fish aFish)
{
    aFish.swim();
}

...

Carp carp1;
MakeFishSwim(carp1);
// Slicing: only the Fish part of Carp is sent to MakeFishSwim() 

Tuna tuna1;
MakeFishSwim(tuna1);

下面实例,使用基于深复制的智能指针将多态对象作为基类对象进行传递:

template <typename T>
class deepcopy_smart_ptr
{
  private:
    T* ptr;
    // copy constructor
    deepcopy_smart_ptr(const deepcopy_smart_ptr& source)
    {
      ptr = source->Clone();
    }
    // assignment operator
    deepcopy_smart_ptr& operator=(const deepcopy_smart_ptr& source)
    {
        if (ptr)
        {
            delete ptr;
        }
        ptr = source->Clone();
        return *this;
    }
};

实现了一个复制构造函数,使得能够通过函数 Clone() 函数对多态对象进行深复制—类必须实现函数 Clone() 。另外,它还实现了复制赋值运算符,为了简单起见,这里假设基类 Fish 实现的虚函数为 Clone() 。通常,实现深复制模型的智能指针通过模板参数或函数对象提供该函数。下面是 deepcopy_smart_ptr 的一种用法:

deepcopy_smart_ptr<Carp> freshWaterFish(new Carp);
MakeFishSwim (freshWaterFish); // Carp will not be 'sliced' 

策略:写时复制(Copy on Write)

COW是一种优化策略,它推迟了资源的复制操作。当你有两个对象共享同一份数据时,COW会让你在第一次修改数据时才真正地去复制它。

COW的核心思想

想象一下,你有一个std::string对象,叫s1,里面存着一段很长的文本。现在你想要用s1去初始化另一个std::string对象s2

std::string s1 = "这是一段很长的文本...";
std::string s2 = s1; // 理论上s2是s1的一个拷贝

如果std::string使用了COW,那么在这一步,s2并不会立刻复制s1的数据。相反,s1s2会共享同一份底层数据。为了实现这一点,通常会有一个引用计数(reference count)来记录有多少个对象正在共享这份数据。在这个例子中,这份数据的引用计数会从1增加到2。

什么时候会触发复制?

复制操作只会在你试图修改其中一个对象时发生。比如,你想修改s2

s2 += ",后面又加了一些新内容。"; // 触发写时复制

在你执行这行代码时,系统会检查s2所指向的数据的引用计数。因为它大于1,所以系统会:

  1. s2分配一块新的内存。
  2. 将原始数据(“这是一段很长的文本…”)从旧地址复制到新地址。
  3. s2指向这份新数据。
  4. 将原始数据的引用计数减1。
  5. 最后,将新内容添加到s2的新数据中。
智能指针的COW应用

写时复制机制(Copy on Write,COW)试图对深复制智能指针的性能进行优化,它共享指针,直到首次写入对象。首次调用非 const 函数时,COW 指针通常为该非 const 函数操作的对象创建一个副本,而其他指针实例仍共享源对象。

COW 深受很多程序员的喜欢。实现 const 和非 const 版本的运算符*和->,是实现 COW 指针功能的关键。非 const 版本用于创建副本。

重要的是,选择 COW 指针时,在使用这样的实现前务必理解其实现细节。否则,复制时将出现复制得太少或太多的情况。

策略:引用计数智能指针

引用计数智能指针,通常指的是 C++ 标准库中的 std::shared_ptr ,它是一种用于管理动态分配对象生命周期的智能指针。它的核心思想是共享所有权(shared ownership),即多个智能指针可以同时指向同一个对象,并且该对象只有 在所有指向它的智能指针都被销毁或重置后,才会自动释放内存。因此,引用计数提供了一种优良的机制,使得可共享对象而无法对其进行复制。

这种智能指针被复制时,需要将对象的引用计数加 1。至少有两种常用的方法来跟踪计数:

  • 在对象中维护引用计数;
  • 引用计数由共享对象中的指针类维护。

前者称为入侵式引用计数,因为需要修改对象以维护和递增引用计数,并将其提供给管理对象的智能指针。COM 采取的就是这种方法。

后者是智能指针类将计数保存在自由存储区(如动态分配的整型),复制时复制构造函数将这个值加 1。

因此,使用引用计数机制,程序员只应通过智能指针来处理对象。在使用智能指针管理对象的同时让原始指针指向它是一种糟糕的做法,因为智能指针将在它维护的引用计数减为零时释放对象,而原始指针将继续指向已不属于当前应用程序的内存。

引用计数还有一个独特的问题:如果两个对象分别存储指向对方的指针,这两个对象将永远不会被释放,因为它们的生命周期依赖性导致其引用计数最少为 1。即循环引用。

循环引用的解决办法

为了解决循环引用的问题,C++ 提供了std::weak_ptr。std::weak_ptr 是一种不增加引用计数的智能指针,它通常用于打破循环引用,或者观察一个对象而不会阻止其被销毁。当你需要访问 std::weak_ptr 所指向的对象时,你需要先将其转换为 std::shared_ptr

区分于 std::shared_ptrstd::weak_ptr 不会增加对象的强引用计数。对应的,被 weak_ptr 指向的引用,成为弱引用。只要有一个 std::weak_ptrstd::shared_ptr 指向控制块,弱引用计数就大于 0。当弱引用计数降为 0 时,控制块才会被销毁。

std::weak_ptr 只会影响弱引用计数,而不会影响强引用计数。这意味着,即使有多个 std::weak_ptr 指向同一个对象,只要没有 std::shared_ptr 指向它,对象随时都可能被销毁。

核心功能:lock() 方法

std::weak_ptr 最大的特点是它不能直接访问所指向的对象。为了安全地使用它,你需要先调用 lock() 方法。

lock() 方法会检查对象是否仍然存在(即强引用计数是否大于 0)。

  • 如果对象存在,lock() 会返回一个临时的 std::shared_ptr。这时,强引用计数会增加 1,确保在你使用这个临时智能指针期间,对象不会被销毁。使用完毕后,这个临时智能指针会自动销毁,引用计数会减 1。
  • 如果对象不存在(已经被销毁),lock() 会返回一个空的 std::shared_ptr。

这个机制非常重要,因为它保证了你在访问对象时,该对象是有效的。

策略:破坏性复制

破坏性复制是这样一种机制,即在智能指针被复制时,将对象的所有权转交给目标指针并重置原来的指针。

destructive_copy_smartptr <SampleClass> smartPtr (new SampleClass ());
SomeFunc (smartPtr); // Ownership transferred to SomeFunc
// Don't use smartPtr in the caller any more!

虽然破坏性复制机制使用起来并不直观,但它有一个优点,即可确保任何时刻只有一个活动指针指向对象。因此,它非常适合从函数返回指针以及需要利用其“破坏性”的情形。

一个破坏性复制智能指针的例子:

template <typename T>
class destructive_copy_smartptr
{
public:
    destructive_copy_smartptr(T* p = nullptr) : _ptr(p) {}
    ~destructive_copy_smartptr() { delete _ptr; }
  
    // 接收的参数并非const类型,是为了实现破坏性复制
    destructive_copy_smartptr& operator=(destructive_copy_smartptr& other)
    {
        _ptr = other._ptr;
        // 赋值后,将外部的原指针设为nullptr
        other._ptr = nullptr;
        return *this;
    }

    destructive_copy_smartptr(destructive_copy_smartptr& other)
    {
        _ptr = other._ptr;
        other._ptr = nullptr;
    }

private:
    T* _ptr;
};

不同于大多数 C++类,该智能指针类的复制构造函数和赋值运算符不能接受 const 引用,因为它在复制源引用后使其无效。这不仅不符合传统复制构造函数和赋值运算符的语义,还让智能指针类的用法不直观。复制或赋值后销毁源引用不符合预期。鉴于这种智能指针销毁源引用,这也使得它不适合用于 STL 容器,如 std::vector 或其他任何动态集合类。这些容器需要在内部复制内容,这将导致指针失效。由于种种原因,不在程序中使用破坏性复制智能指针是明智的选择。

使用 std::unique_ptr

C++标准一直支持 auto_ptr,它是一种基于破坏性复制的智能指针。C++11 终于摒弃了该智能指针,现在您应使用 std::unique_ptr

unique_ptr 是一种简单的智能指针,但其复制构造函数和赋值运算符被声明为私有的,因此不能复制它,即不能将其按值传递给函数,也不能将其赋给其他指针。

要使用 std:unique_ptr ,必须包含头文件<memory>

#include <memory> 

使用举例:

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

class Fish
{
  public:
    Fish() { cout << "Fish constructor" << endl; }
    ~Fish() { cout << "Fish destructor" << endl; }
    void Swim() { cout << "Fish swim" << endl; }
};

void MakeFishSwim(const unique_ptr<Fish>& inFish)
{
    inFish->Swim();
}

int main()
{
    unique_ptr<Fish> pFish(new Fish);
    pFish->Swim();

    MakeFishSwim(pFish);

    unique_ptr<Fish> pFish2;
    // error: operator= is private
    // pFish2 = pFish;

    return 0;
}

可以看到,pFish指向的对象是在main函数中创建的,当main函数结束时,pFish指向的对象会自动销毁,无需手动调用delete。总之,unique_ptr 比 C++11 已摒弃的 auto_ptr 更安全,因为复制和赋值不会导致源智能指针对象无 效。它在销毁时释放对象。

unique_ptr 支持移动语义,即可以将一个 unique_ptr 移动到另一个 unique_ptr 中。移动语义可以避免复制对象,提高效率。

移动语义的使用举例:

#include <iostream>
using namespace std;

int main()
{
  unique_ptr<int> ptr1(new int(1));
  cout << *ptr1 << endl;
  cout << ptr1.get() << endl;

  unique_ptr<int> ptr2 = move(ptr1);
  cout << *ptr2 << endl;
  cout << ptr2.get() << endl;

  // 移动后,ptr1 不再指向原对象,而是由ptr2指向这个对象
  // ptr1 指向 nullptr
  if(ptr1)
  {
    cout << *ptr1 << endl;
  } 
  else
  {
    cout << "ptr1 is null" << endl;
  }

  return 0;
}

深受欢迎的三方智能指针库

显然,C++标准库提供的智能指针并不能满足所有程序员的需求,这就是还有很多其他智能指针库的原因。

Boost 提供了一些经过测试且文档完善的智能指针类,还有很多其他的实用类。

有关 Boost 智能指针的更详细信息,请访问 boost smart_ptr,在这里还可下载相关的库。

使用流进行输入和输出

C++ 的流(Streams)是处理输入/输出(I/O)的核心机制。它抽象了数据源和数据目的地,将数据的读取和写入操作统一起来,使得开发者可以用同样的方式处理来自不同地方的数据,比如文件、键盘、屏幕、网络等。

你可以把“流”想象成一条水管:

  • 输入流(Input Stream):数据从源头流向你的程序,就像水从水源流进水管一样。
  • 输出流(Output Stream):数据从你的程序流向目的地,就像水从水管流出去一样。

流类库的核心概念

C++ 标准库中的流类库(通常称为 iostream)主要由几个基类组成,它们共同构成了流处理的基础:

  1. std::istream (输入流)

    • 处理从外部设备读取数据的操作。
    • 例如,从键盘读取数据用到的 std::cin 就是 std::istream 的一个实例。
    • 常用的操作符是提取操作符 >>,例如 std::cin >> myVar;
  2. std::ostream (输出流)

    • 处理向外部设备写入数据的操作。
    • 例如,向屏幕输出数据用到的 std::cout 就是 std::ostream 的一个实例。
    • 常用的操作符是插入操作符 <<,例如 std::cout << "Hello, world!";
  3. std::iostream (输入/输出流)

    • 同时支持输入和输出,通常用于处理可以双向通信的设备。
    • 例如,文件流 std::fstream 就继承自这个类。

重要的流类和流对象

| 类/对象 | 用途 | | — | — | | cout | 标准输出流,通常被重定向到控制台 | | cin | 标准输入流,通常用于将数据读入变量 | | cerr | 用于显示错误信息的标准输出流 | | fstream | 用于操作文件的输入和输出流,继承了 ofstream 和 ifstream | | ofstream | 用于操作文件的输出流类,即用于创建文件 | | ifstream | 用于操作文件的输入流类,即用于读取文件 | | stringstream | 用于操作字符串的输入和输出流类,继承了 istringstream 和 ostringstream,通常用于在字符串和其他类型之间进行转换 |

cout、cin 和 cerr 分别是流类 ostream、istream 和 ostream 的全局对象。由于是全局对象,它们在 main( )开始之前就已初始化。

使用流类时,可指定为您执行特定操作的控制符(manipulator)。std::endl 就是一个这样的控制符,您一直在使用它来插入换行符:

std::cout << "This lines ends here" << std::endl;

std命名空间常用于流的控制符

| 控制符 | 用途 | | — | — | | 输出控制符 | | endl | 插入一个换行符 | | ends | 插入一个空字符 | | 基数控制符 | | dec | 让流以十进制方式解释输入或显示输出 | | hex | 让流以十六进制方式解释输入或显示输出 | | oct | 让流以八进制方式解释输入或显示输出 | | 浮点数表示控制符 | | fixed | 让流以定点表示法显示数据 | | scientific | 让流以科学表示法显示数据 | | <iomanip> 控制符 | | setprecision | 设置小数精度 | | setw | 设置字段宽度 | | setfill | 设置填充字符 | | setbase | 设置基数,与使用 dec、hex 或 oct 等效 | | setiosflag | 通过类型为 std::ios_base::fmtflags 的掩码输入参数设置标志 | | resetiosflag | 将 std::ios_base::fmtflags 参数指定的标志重置为默认值 |

std::cout 指定格式写入控制台

修改数字显示格式

可以让 cout 以十六进制或八进制方式显示整数。

#include <iostream>
using namespace std;

int main()
{
  int num = 255;

  cout << "Decimal: " << dec << num << endl;
  cout << "Hexadecimal: " << hex << num << endl;
  cout << "Octal: " << oct << num << endl;

  return 0;
}

setiosflags 是 C++ <iomanip> 头文件中的一个函数,主要用于设置输出流的格式标志。这些格式标志决定了数据在输出时的显示方式,比如对齐方式、数字基数、是否显示正负号等。

对上面的代码进一步使用 setiosflags 函数,打印大写十六进制字母。

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

int main()
{
  int num = 255;

  cout << "Integer in hex using base notation: ";
  cout << setiosflags(ios_base::hex|ios_base::showbase|ios_base::uppercase); 

  cout << "Hexadecimal: " << num << endl;

  return 0;
}

在 cout « 链式输出中,setiosflags 的作用范围没有生效。根本原因在于 setiosflags 操纵符是在它所在的流(cout)上设置格式标志,但这个设置并不会立即影响到它后面的第一个字符串字面量 “Hexadecimal: “。cout « setiosflags(…) 这条语句会先执行,把 cout 的输出格式设置为十六进制、显示基数前缀并使用大写字母。但是,紧接着的 cout « “Hexadecimal: “ 是一个字符串,它不受这些数字格式的影响。当 cout 遇到下一个可以被格式化的数据,也就是你的整数变量 num 时,cout 的输出格式已经被重置了。

写作:

cout << "Integer in hex using base notation: " << setiosflags(ios_base::hex|ios_base::showbase|ios_base::uppercase) << num << endl;

仍然失效,暂不清楚原因,但是有一个更现代的写法是可以生效的:

cout << "Hexadecimal: " << hex << showbase << uppercase << num << endl;

另一个例子,使用 cout 以定点表示法和科学表示法显示 Pi 和圆面积:

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

int main()
{
  const double pi = (double) 22.0 / 7.0;
  double radius = 5.0;
  double area = pi * radius * radius;

  cout << fixed << setprecision(7);
  cout << "Pi: " << pi << endl;
  cout << scientific << "Scientific: " << pi <<endl;
  
  cout << "Area: " << area << endl;

  return 0;
}

result:

Pi: 3.1428571
Scientific: 3.1428571e+00
Area: 7.8571429e+01

使用 std::cout 对齐文本和设置字段宽度

可使用 setw() 控制符来设置字段宽度,插入到流中的内容将在指定宽度内右对齐。在这种情况下,还可使用 setfill() 指定使用什么字符来填充空白区域。

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

int main()
{
  cout << endl;
  cout << setw(30) << "Hello" << endl;
  cout << setw(10) << setfill('*') << "World" << endl;
  return 0;
}

使用 std::cin 进行输入

使用 std::cin 将输入读取到基本类型变量中

std::cin 用途广泛,让您能够将输入读取到基本类型(如 int、double 和 char*)变量中。您还可使用 getline() 从键盘读取一行输入。

使用实例:

#include <iostream>
using namespace std;

int main()
{
  int num;
  cout << "Enter an integer: ";
  cin >> num;
  cout << "You entered: " << num << endl;

  double pi;
  cout << "Enter the value of Pi: ";
  cin >> pi;
  cout << "You entered: " << pi << endl;

  char ch1, ch2, ch3;
  cout << "Enter three characters separated by spaces: ";
  cin >> ch1 >> ch2 >> ch3;
  cout << "You entered: " << ch1 << ch2 << ch3 << endl;

  return 0;
}

使用cin::get安全地读取字符

cin 让您能够将输入直接写入 int 变量,也可将输入直接写入 char 数组(C 风格字符串):

cout << "Enter a line: " << endl;
char charBuf [10] = {0}; // can contain max 10 chars
cin >> charBuf; // Danger: user may enter more than 10 chars

写入 C 风格字符串缓冲区时,务必不要超越缓冲区的边界,以免导致程序崩溃或带来安全隐患,这至关重要。因此,将输入读取到 char 数组(C 风格字符串)时,下面是一种更好的方法:

cout << "Enter a line: " << endl;
char charBuf[10] = {0};
cin.get(charBuf, 9); // stop inserting at the 9th character

使用实例:

#include <iostream>
using namespace std;

int main()
{
  char charBuf[10] = {0};
  cout << "Enter something:";
  cin.get(charBuf, 9);
  cout << "You entered: " << charBuf << endl;
  return 0;
}

只要可以,就使用 std::string , 而不是C风格字符串。

使用 std::string 进行承接

按照其他类型一样的写法来接收字符串时,会有一个限制,就是遇到用户输入空格,就停止了插入。例如:

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

int main()
{
  string str;
  cout << "Enter string:";
  cin >> str;
  cout << "You entered: " << str << endl;
  return 0;
}

输入“Hello world”,只接收了“Hello”,空格后面的“world”被忽略了。

要读取整行输入(包括空白),需要使用 getline( ):

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

int main()
{
  string str;
  cout << "Enter your name:";
  getline(cin, str);
  cout << "You entered: " << str << endl;
  return 0;
}

使用 std::fstream 进行文件操作

要使用 std::fstream 类或其基类,需要包含头文件 <fstream>

#include <fstream> 

要使用 fstream、ofstream 或 ifstream 类,需要使用方法 open( )打开文件:

fstream myFile;
myFile.open("HelloFile.txt",ios_base::in|ios_base::out|ios_base::trunc);
if (myFile.is_open()) // check if open() succeeded
{
 // do reading or writing here
 myFile.close();
}

open() 接受两个参数:第一个是要打开的文件的路径和名称(如果没有提供路径,将假定为应用程序的当前目录设置);第二个是文件的打开模式。在上述代码中,指定了模式 ios_base::trunc(即便指定的文件存在,也重新创建它)、ios_base::in(可读取文件)和 ios_base::out(可写入文件)。

注意到在上述代码中使用了 is_open( ),它检测 open( )是否成功。

保存到文件时,必须使用 close() 关闭文件流。无论是使用构造函数还是成员方法 open() 来打开文件流,都建议您在使用文件流对象前,使用 is_open()检查文件打开操作是否成功。

还有另一种打开文件流的方式,那就是使用构造函数:

fstream myFile("HelloFile.txt",ios_base::in|ios_base::out|ios_base::trunc);

如果只想打开文件进行写入,可使用如下代码:

ofstream myFile("HelloFile.txt", ios_base::out);

如果只想打开文件进行读取,可使用如下代码:

ifstream myFile("HelloFile.txt", ios_base::in); 

可在下述各种模式下打开文件流。

  • ios_base::app:附加到现有文件末尾,而不是覆盖它。
  • ios_base::ate:切换到文件末尾,但可在文件的任何地方写入数据。
  • ios_base::trunc:导致现有文件被覆盖,这是默认设置。
  • ios_base::binary:创建二进制文件(默认为文本文件)。
  • ios_base::in:以只读方式打开文件。
  • ios_base::out:以只写方式打开文件。

打开文件并使用«写入内容

代码示例:

#include <iostream>
#include <string>
#include <fstream>
using namespace std;

int main()
{
  // 使用open打开一个文件,输入内容
  ofstream myFile;

  myFile.open("test.txt", ios_base::out);

  myFile << "Open File Success" << endl;

  myFile << "hello world" << endl;

  myFile.close();

  return 0;
}

打开文件并使用»读取内容

代码示例:

#include <iostream>
#include <string>
#include <fstream>
using namespace std;

int main()
{
  // 使用open打开一个文件,输入内容
  ifstream myFile;

  myFile.open("test.txt", ios_base::in);
  
  if(myFile.is_open())
  {
    cout << "Open File Success" << endl;
    string content;
    while(myFile.good())
    {
      getline(myFile, content);
      cout << content << endl;
    }
    myFile.close();
  }
  else
  {
    cout << "Open File Failed" << endl;
  }

  return 0;
}

ifstream的good函数什么作用

ifstream 的 good() 函数用于检查输入流的当前状态,以确定它是否可以继续进行有效的输入/输出操作。

good() 函数的作用,good() 函数会返回一个布尔值:

  • true:如果流没有设置任何错误标志。这意味着文件流处于“正常”状态,没有遇到文件结束、读取失败或格式错误等问题。你可以安全地继续读取数据。
  • false:如果流的以下任何一个或多个错误标志被设置了:
    • failbit: 发生了非致命的 I/O 错误。例如,你试图将一个非数字字符读入一个整数变量。
    • eofbit: 达到了文件末尾 (End-Of-File)。这意味着已经没有更多数据可供读取。
    • badbit: 发生了致命的 I/O 错误。例如,磁盘读取错误。

读写二进制文件

写入二进制文件的流程与前面介绍的流程差别不大,重要的是在打开文件时使用 ios_base::binary 标志。通常使用 ofstream::writeifstream::read 来读写二进制文件。

将一个结构写入二进制文件,并且使用该文件恢复出一个结构:

#include <fstream>
#include <iostream>

using namespace std;

struct Student {
    int id;
    char name[20];
    int age;
};

int main() {
    Student s1 = {1, "zhangsan", 18};
    ofstream myFile("test.bin", ios_base::out | ios_base::binary);
    if (myFile.is_open()) {
        myFile.write(reinterpret_cast<const char *>(&s1), sizeof(Student));
        myFile.close();
    }
    Student s2;
    ifstream myFile2("test.bin", ios_base::in | ios_base::binary);
    if (myFile2.is_open()) {
        myFile2.read((char *)&s2, sizeof(Student));
        myFile2.close();
        cout << s2.id << endl;
        cout << s2.name << endl;
        cout << s2.age << endl;
    }
    return 0;
}
  • 使用了 ifstream::readofstream::write 来读写文件;
  • 使用了 reinterpret_cast 来将结构转换为字符指针。和下面的强转效果是一样的;

结构化的数据存储到 XML 文件中是更好的选择。XML 是一种基于文本和标记的存储格式,在持久化信息方面提供了灵活性和可扩展性。发布这个程序后,如果您对其进行升级,给结构 Human添加了新属性(如 numChildren),则需要考虑新版本使用的 ifstream::read,确保它能够正确地读取旧版本创建的二进制 数据。

使用 std::stringstream 对字符串进行转换

假设您有一个字符串,它包含字符串值 45,如何将其转换为整型值 45 呢?如何将整型值 45 转换为字符串 45 呢?C++提供的 stringstream 类是最有用的工具之一,让您能够执行众多的转换操作。

要使用 std::stringstream 类,需要包含头文件 <sstream>

#include <sstream> 

使用实例:

#include <fstream>
#include <iostream>
#include <sstream>
#include <string>

using namespace std;

int main() {
    cout << "Enter an integer: " << endl;
    int input = 0;
    cin >> input;

    stringstream converterStream;
    converterStream << input;

    string inputStr;
    converterStream >> inputStr;

    cout << "You entered: " << inputStr << endl;

    stringstream anotherStream;
    anotherStream << inputStr;
    int anotherInput = 0;
    anotherStream >> anotherInput;

    cout << "The integer value is: " << anotherInput << endl;

    return 0;
}

该程序让用户输入一个整型值,并使用运算符 << 将其插入到一个 stringstream 对象中。然后,您使用提取运算符将这个整数转换为 string。接下来,您将存储在 inputAsStr 中的字符串转换为整数,并将其存储到 Copy 中。

流小结

  • 只想读取文件时,务必使用 ifstream。
  • 只想写入文件时,务必使用 ofstream。
  • 插入文件流或从文件流中提取之前,务必使用 is_open() 核实是否成功地打开了它。
  • 使用完文件流后,别忘了使用方法 close() 将其关闭。
  • 别忘了,使用代码 cin»strData;从 cin 提取内容到 string 中时,通常导致 strData 只包含空白前的文本,而不是整行。
  • 别忘了,函数 getline(cin, strData); 从输入流中获取整行,其中包括空白。

异常处理

现实世界千差万别,没有两台计算机是相同的,即便硬件配置一样。这是因为在特定时间,可用的资源量取决于计算机运行的软件及其状态,因此即便在开发环境中内存分配完美无缺,在其他环境中也可能出问题。

这些问题导致了异常。异常会打断应用程序的正常流程。毕竟,如果没有内存可用,应用程序就无法完成分配给它的任务。然而,应用程序可处理这种异常:向用户显示一条友好的错误消息、采取必要的挽救措施并妥善地退出。

异常可能是外部因素导致的,如系统没有足够的内存;也可能是应用程序内部因素导致的,如使用的指针包含无效值或除数为零。为了向调用者指出错误,有些模块引发异常。

通过对异常进行处理,有助于避免出现“访问违规”和“未处理的异常”等屏幕,还可避免收到相关的抱怨邮件。下面来看看 C++都向您提供了哪些应对意外的工具。

使用try catch 处理异常

在捕获异常方面, trycatch 是最重要的 C++关键字。要捕获语句可能引发的异常,可将它们放在 try 块中,并使用 catch 块对 try 块可能引发的异常进行处理:

void SomeFunc() {
    try {
        int* numPtr = new int;
        *numPtr = 999;
        delete numPtr;
    } catch (...)  // ... catches all exceptions
    {
        cout << "Exception in SomeFunc(), quitting" << endl;
    }
}

使用举例,用户输入为 -1 个整数预留空间:

#include <iostream>
using namespace std;

int main() {
    try {
        cout << "Enter the size of the array: " << endl;
        int size = 0;
        cin >> size;
        int* numPtr = new int[size];
        cout << "the array size is: " << size << endl;
    } catch (...) {
        cout << "Exception in main(), quitting" << endl;
    }
    return 0;
}

使用 catch(...) 可以捕获所有异常。在这个场景下,也可以指定 const std::bad_alloc& e 来专门捕获因为 new 失败引发的异常。

也可以先指定 const std::bad_alloc& e 来捕获异常,然后再使用 catch(...) 来捕获其他异常。确保万无一失。

#include <iostream>
using namespace std;

int main() {
    try {
        cout << "Enter the size of the array: " << endl;
        int size = 0;
        cin >> size;
        int* numPtr = new int[size];
        cout << "the array size is: " << size << endl;
    } catch (std::bad_alloc& exp) {
        cout << "Exception encountered: " << exp.what() << endl;
        cout << "Got to end, sorry!" << endl;
    } catch (...) {
        cout << "Exception in main(), quitting" << endl;
    }
    return 0;
}

输出:

Enter the size of the array: 
-1
Exception encountered: std::bad_array_new_length
Got to end, sorry!

一般而言,可根据可能出现的异常添加多个 catch( )块,这将很有帮助。

使用 throw 引发特定类型的异常

void DoSomething()
{
 if(something_unwanted)
 throw object;
} 

使用举例:

#include <iostream>
using namespace std;

int Divide(int a, int b) {
    if (b == 0) {
        throw "Division by zero";
    }
    int result = a / b;
    cout << "Result: " << result << endl;
    return result;
}

int main() {
    int a = 10;
    int b = 0;
    int result = 0;
    try {
        result = Divide(a, b);
    } catch (const char* exp) {
        cout << "Exception encountered: " << exp << endl;
    }
    return 0;
}

上述代码表明,通过捕获类型为 char* 的异常,可捕获调用函数 Divide() 可能引发的异常。另外,这里没有将整个 main() 都放在 try{ } ;中,而 只在其中包含可能引发异常的代码 。这通常是一种不错的做法,因为 异常处理也可能降低代码的执行性能

异常处理的工作流程

在程序清单 28.3 中,您在函数 Divide( ) 中引发了一个类型为 char* 的异常,并在函数 main() 中使用处理程序 catch(char*) 捕获它。

每当您使用 throw 引发异常时,编译器都将查找能够处理该异常的 catch(Type) 。异常处理逻辑首先检查引发异常的代码是否包含在 try 块中,如果是,则查找可处理这种异常的 catch(Type) 。如果 throw 语句不在 try 块内,或者没有与引发的异常兼容的 catch() ,异常处理逻辑将继续在调用函数中寻找。因此,异常处理逻辑沿调用栈向上逐个地在调用函数中寻找,直到找到可处理异常的 catch(Type) 。在退栈过程的每一步中,都将销毁当前函数的局部变量,因此这些局部变量的销毁顺序与创建顺序相反。

演示:

#include <iostream>
using namespace std;

struct StructA {
    StructA() { cout << "StructA constructor" << endl; }
    ~StructA() { cout << "StructA destructor" << endl; }
};

struct StructB {
    StructB() { cout << "StructB constructor" << endl; }
    ~StructB() { cout << "StructB destructor" << endl; }
};

void FunctionTwo() {
    StructA a;
    StructB b;
    cout << "About to Throw an Exception" << endl;
    throw "Exception in FunctionTwo()";
}

void FunctionOne() {
    try {
        StructA a;
        StructB b;
        FunctionTwo();
    } catch (const char* exp) {
        cout << "Exception encountered: " << exp << endl;
        cout << "Exception Handled in FunctionOne(), not gonna pass to Caller"
             << endl;
    }
}

int main() {
    cout << "About to call FunctionOne()" << endl;
    try {
        FunctionOne();
    } catch (const char* exp) {
        cout << "Exception encountered: " << exp << endl;
    }
    cout << "Everything alright! About to exit main()" << endl;
    return 0;
}

按照栈上的顺序,创建和销毁对象,在 FunctionOne() 中捕获了异常,就不会传递到 main() 中。

如果因出现异常而被调用的析构函数也引发异常,将导致应用程序异常终止。

std::exception类

例如捕获 std::bad_alloc 时,实际上是捕获 new 引发的 std::bad_alloc 对象。std::bad_alloc 继承了 C++标准类 std::exception,而 std::exception 是在头文件 <exception> 中声明的。

下述重要异常类都是从 std::exception 派生而来的。

  • bad_alloc:使用 new 请求内存失败时引发。
  • bad_cast:试图使用 dynamic_cast 转换错误类型(没有继承关系的类型)时引发。
  • ios_base::failure:由 iostream 库中的函数和方法引发。

std::exception 类是异常基类,它定义了虚方法 what() ;这个方法很有用且非常重要,详细地描述了导致异常的原因,让用 户知道什么地方出了问题。

由于 std::exception 是众多异常类型的基类,因此可使用 catch(const exception&) 捕获所有将 std::exception 作为基类的异常:

void SomeFunc() {
    try {
        // code made exception safe
    } catch (const std::exception& exp)  // catch bad_alloc, bad_cast, etc
    {
        cout << "Exception encountered: " << exp.what() << endl;
    }
}

从 std::exception 派生出自定义异常类

可以引发所需的任何异常。然而,让自定义异常继承 std::exception 的好处在于,现有的异常处理程序 catch(const std::exception&)不但能捕获 bad_alloc、bad_cast 等异常,还能捕获自定义异常,因为它们的基类都是 exception。

举例:

#include <iostream>
#include <string>

using namespace std;

class CustomException : public exception {
    string reason;

   public:
    CustomException(const string& reason) : reason(reason) {}

    virtual const char* what() const throw() { return reason.c_str(); }
};

int Divide(int a, int b) {
    if (b == 0) {
        throw CustomException("Divisor cannot be zero");
    }
    return a / b;
}

int main() {
    try {
        int result = Divide(10, 0);
        cout << "Result: " << result << endl;
    } catch (const CustomException& e) {
        cout << "Exception: " << e.what() << endl;
    }
    return 0;
}

请注意 CustomException::what() 的声明:

virtual const char* what() const throw()

它以 throw() 结尾,这意味着这个函数本身不会引发异常。这是对异常类的一个重要约束,如果您在该函数中包含一条 throw 语句,编译器将发出警告。如果函数以 throw(int) 结尾,意味着该函数可能引发类型为 int 的异常。

异常小结

  • 务必捕获类型为 std::exception 的异常。
  • 务必从 std::exception 派生出自定义异常类。
  • 务必谨慎地引发异常。异常不能替代返回值(如true 或 false)。
  • 不要在析构函数中引发异常。
  • 不要认为内存分配总能成功,务必将使用 new 的代码放在 try 块中,并使用 catch(std::exception&)捕获可能发生的异常。
  • 不要在catch( )块中包含实现逻辑或分配资源的代码,以免在处理异常的同时导致异常。
- 问:为何引发异常,而不是返回错误?
- 答:不是什么时候都可以返回错误。如果调用 new 失败,需要处理 new 引发的异常,以免应用程序崩溃。另外,如果错误非常严重,导致应用程序无法正常运行,应考虑引发异常。

- 问:为何自定义异常类应继承 std::exception?
- 答:当然,并非必须这样做,但这让您能够重用捕获 std::exception 异常的所有 `catch( )` 块。编写自己的异常类时,可以不继承任何类,但必须在所有相关的地方插入新的 `catch(MyNewExceptionType&)` 语句。

- 问:我编写的函数引发异常,必须在该函数中捕获它吗?
- 答:完全不必,只需确保调用栈中有一个函数捕获这类异常即可。

- 问:构造函数可引发异常吗?
- 答:构造函数实际上没有选择余地!它们没有返回值,指出问题的唯一途径是引发异常。

- 问:析构函数可引发异常吗?
- 答:从技术上说可以,但这是一种糟糕的做法,因为异常导致退栈时也将调用析构函数。如果因异常而调用的析构函数引发异常,将给原本就稳定并试图妥善退出的应用程序雪上加霜。