C++程序设计语言笔记——引言:第三章 C++概览之抽象机制1

C++概览:抽象机制

0 类

C++最核心的语言特性就是类。类是一种用户自定义的数据类型,用于在程序代码中表示某种概念。无论何时,只要我们想为程序设计一个有用的概念、想法或实体,都应该设法把它表示为程序中的一个类,这样我们的想法就能表达成代码,而不是仅存在于我们的头脑中、设计文档里或者注释里。对于一个程序来说,不论是用易读性还是正确性来衡量,使用一组精挑细选的类都要比直接用内置类型完成所有任务更好,尤其是当选用由库提供的类时更是如此。
从本质上来说,基础类型、运算符和语句之外的所有语言特性存在的目的就是帮助我们定义更好的类以及更方便地使用它们。在这里,“更好”的意思包括更加正确、更容易实现、更有效率、更优雅、更易用以及更易推断。大多数编程技术依赖于某些特定类的设计与实现。程序员要完成的任务千差万别,因此对类的支持也应该是宽泛和丰富的。接下来,我们只考虑对三种重要的类的基本支持:
● 具体类;
● 抽象类;
● 类层次中的类。
很多有用的类都可以归为这三个类别,其他类也可以看成是这些类别的简单变形或组合。
具体类
具体类的基本思想是它们的行为“就像内置类型一样”。例如,一个复数类型和一个无穷精度整数与内置的 int 非常相像,当然它们有自己的语义和操作集合。同样,vector 和 string 也很像内置的数组,只不过在可操作性上更胜一筹。
具体类型的典型特征是,它的表现形式是其定义的一部分。在很多重要例子(如 vector)中,表现形式只不过是一个或几个指向保存在别处的数据的指针,但这种表现形式出现在具体类的每一个对象中。这使得实现可以在时空上达到最优,尤其是它允许我们:

  • 把具体类型的对象置于栈、静态分配的内存或者其他对象中;
  • 直接引用对象(而非仅仅通过指针或引用);
  • 创建对象后立即进行完整的初始化(比如使用构造函数);
  • 拷贝对象。

类的表现形式可以被限定为私有的(就像 Vector 一样),只能通过成员函数访问,但它确实存在。因此,一旦表现形式发生了任何明显的改动,使用者就必须重新编译。这也是我们试图使具体类型尽可能接近内置类型而必须付出的代价。对于那些不常改动的类型和那些局部变量提供了迫切需要的清晰性和效率的类型来说,这种特性是可以接受的,而且通常会很理想。如果想提高灵活性,具体类型可以将其表现形式的主要部分放置在自由存储(动态内存、堆)中,然后通过存储在类对象内部的另一部分访问它们。vector 和 string 的实现机理正是如此,我们可以把它们看做是带有精致接口的资源管理器。
一种“经典的用户自定义算术类型”是 complex:

class complex
{
	//这两行定义了 complex 类的两个私有成员变量 re 和 im,分别用于存储复数的实部和虚部。
	// 这两个变量都是 double 类型的,意味着它们可以存储双精度浮点数。
	double re,im;
public:
	//这里定义了三个构造函数,用于创建 complex 类的实例:
    //第一个构造函数接受两个 double 类型的参数 r 和 i,分别初始化复数的实部和虚部。
	//第二个构造函数接受一个 double 类型的参数 r,将复数的实部初始化为 r,虚部初始化为 0。这表示创建了一个实数(虚部为0的复数)。
	//第三个构造函数是默认构造函数,不接受任何参数,将复数的实部和虚部都初始化为 0。
	complex(double r, double i) :re{ r }, im{ i } {}//用两个标量构建该复数,
	complex(double r) :re{r}, im{ 0 } {}//用一个标量构建该复数 
	complex() :re{ 0 }, im{ 0 } {}//默认的复数是{0,0}

	//这里定义了两对成员函数,用于获取和设置复数的实部和虚部:
    //real() 和 imag() 函数分别返回复数的实部和虚部。
	//real(double d) 和 imag(double d) 函数分别设置复数的实部和虚部为 d。
	double real() const { return re; }
	void real(double d) { re = d; }
	double imag() const { return im; }
	void imag(double d){ im = d; }

	//这部分代码重载了 += 和 -= 运算符,使得可以直接对两个 complex 对象进行加减操作,并修改调用对象(即左操作数)的值。
	// 例如,c1 += c2; 会将 c2 的实部和虚部分别加到 c1 的实部和虚部上。
    //*= 和 /= 运算符重载的声明在这里给出,但它们的定义被放在了类的外部。
	// 这意味着你需要在这个类的定义之外,提供这两个运算符重载的具体实现,用于复数的乘法和除法操作。
	complex& operator+=(complex z) { re += z.re, im += z.im;return *this; } //加到re和im上然后返回
	complex& operator-=(complex z) { re -= z.re, im -= z.im;return *this; }
	complex& operator*=(complex);//在类外的某处进行定义 
	complex& operator/=(complex);//在类外的某处进行定义
};

这是对标准库 complex略作简化后的版本,类定义本身仅包含需要访问其表现形式的操作。它的表现形式非常简单,也是大家约定俗成的。出于编程实践的要求,它必须兼容 50 年前 Fortran 语言提供的版本,还需要一些常规的运算符。除了满足逻辑上的要求外,complex 还必须足够高效,否则依然没有实用价值。这意味着我们应该将简单的操作设置成内联的。也就是说,在最终生成的机器代码中,一些简单的操作(如构造函数、+=和 imag()等)不能以函数调用的方式实现。定义在类内部的函数默认是内联的。一个工业级的 complex(就像标准库中的那个一样)必须精心实现,并且恰当地使用内联。
无须实参就可以调用的构造函数称为默认构造函数,complex()是 complex 的默认构造函数。通过定义默认构造函数,可以有效防止该类型的对象未初始化。
在负责返回复数实部和虚部的函数中,cost 说明符表示这两个函数不会修改所调用的对象。
很多有用的操作并不需要直接访问 complex 的表现形式,因此它们的定义可以与类的定义分离开来:

complex operator+(complex a, complex b) { return a += b; }
complex operator-(complex a, complex b) { return a -= b; }
complex operator-(complex a) { return{ -a.real(),-a.imag() }; }//一元负号
complex operator*(complex a, complex b) { return a*b; }
complex operator/(complex a, complex b) { return a/b; }

此处我们利用了C++的一个特性,即,以传值方式传递实参实际上是把一份副本传递给函数,因此我们修改形参(副本)不会影响主调函数的实参,并可以将结果作为返回值。
这里需要引入构造函数的概念:
在C++中,构造函数是一个特殊的成员函数,其主要负责在创建类的新对象时初始化该对象的成员变量。构造函数与类同名,且没有返回类型(连void都没有)。当使用类的构造函数创建对象时,会自动调用相应的构造函数来完成初始化工作。
以下是关于C++中构造函数的详细解释:

  • 1.构造函数的定义
    构造函数在类内部声明,并在类外部(通常)进行定义。其特殊之处在于与类名完全相同,并且不接受任何返回类型。
class MyClass {
public:
    int value;
    
    // 构造函数的声明
    MyClass(int v);
};

// 构造函数的定义
MyClass::MyClass(int v) : value(v) {
    // 构造函数体,此处可进行更多初始化操作
}

在上述例子中,MyClass类的构造函数接受一个整型参数v,并将其赋值给成员变量value。

  • 2.构造函数的调用
    当创建类的对象时,会自动调用对应的构造函数。
MyClass obj(10);  // 调用MyClass的构造函数,并传入参数10
  • 3 默认构造函数
    若类中未显式定义任何构造函数,编译器会自动生成一个默认的无参构造函数。此构造函数不执行任何操作,仅是一个空函数体。
class MyClass {
public:
    int value;
    // 未定义构造函数,编译器将生成默认的无参构造函数
};

MyClass obj;  // 调用默认构造函数

在上述例子中,由于MyClass中未定义构造函数,编译器会自动生成一个默认的无参构造函数,并在创建obj对象时调用。

  • 4.构造函数的重载
    C++支持构造函数的重载,即同一类中可以有多个构造函数,只要它们的参数列表不同即可。
class MyClass {
public:
    int value;
    
    // 无参构造函数
    MyClass() : value(0) {}
    
    // 带一个参数的构造函数
    MyClass(int v) : value(v) {}
};

MyClass obj1;       // 调用无参构造函数
MyClass obj2(10);   // 调用带一个参数的构造函数
  • 5.初始化列表(本例中使用
    在构造函数中,可以使用初始化列表来更高效地初始化成员变量。初始化列表位于构造函数的参数列表之后,并由冒号:开始,后跟成员变量的初始化。
class MyClass {
public:
    int value;
    
    MyClass(int v) : value(v) {
        // 构造函数体,此时value已被初始化为v
    }
};

使用初始化列表通常比在构造函数体内赋值更高效,特别是对于需要通过构造函数初始化的复杂类型(如对象成员、引用等)。

  • 6.拷贝构造函数
    拷贝构造函数是一种特殊的构造函数,用于通过另一个同类型的对象来初始化新对象。若未显式定义,编译器会自动生成默认的拷贝构造函数。
class MyClass {
public:
    int value;
    
    // 拷贝构造函数的声明与定义
    MyClass(const MyClass& other) : value(other.value) {}
};

MyClass obj1(10);       // 调用带一个参数的构造函数
MyClass obj2 = obj1;    // 调用拷贝构造函数

在上述例子中,obj2是通过obj1使用拷贝构造函数创建的。

==和!=的定义非常直观且易于理解:

//这个函数重载了 == 操作符,用于比较两个 complex 类型的对象是否相等。
//它比较了两个复数的实部(real() 方法返回的值)和虚部(imag() 方法返回的值)。
//只有当两个复数的实部和虚部分别相等时,这个函数才返回 true,表示这两个复数相等;否则返回 false。
bool operator== (complex a, complex b)//相等
{
	return a.real() == b.real() && a.imag() == b.imag();
}

//这个函数重载了 != 操作符,用于判断两个 complex 类型的对象是否不相等。
// 它通过调用之前定义的 == 操作符,并对其结果取反来实现。
// 如果两个复数不相等(即 a == b 为 false),则 !(a == b) 为 true,表示这两个复数不相等;反之亦然。
bool operator!=(complex a, complex b)//不等
{
	return !(a == b);
}

我们可以像下面这样使用complex:

void f(complex z)
{
	complex a{ 2.3 };
	complex b{ 1 / a };
	complex c{ a + z * complex{1,2,3} };
	//...
	if (c != b)
	{
		c = -(b / a) + 2 * b;
	}
}

编译器自动地把计算 complex 值的运算符转换成对应的函数调用,例如 cl=b 意味着 operator!=(c,b),而 1/a 意味着 operator/(complex{1},a)。
在使用用户自定义的运算符(“重载运算符”)时,我们应该尽量小心谨慎,并且尊重其常规的使用习惯。你不能定义一元运算符 1,因为其语法在语言中已被固定。同样,你不可能改变一个运算符操作内置类型时的含义,因此不能重新定义运算符+令其执行 int 的减法。
这里需要引入重载的概念:
在编程中,重载(Overloading) 是一种多态性特性,它允许在同一作用域内,为函数或操作符创建多个同名但参数列表(包括参数的数量、类型和顺序)不同的版本。这种特性使得函数或操作符能够以多种方式被调用,根据提供的参数来决定具体使用哪一个版本。

  • 函数重载
    函数重载是最常见的重载形式。它允许在同一个作用域内定义多个同名函数,但这些函数的参数列表必须不同。编译器在编译时根据函数调用时提供的参数来决定使用哪个版本的函数。
    例如:
int add(int a, int b);
double add(double a, double b);
complex add(complex a, complex b);

这里定义了三个名为 add 的函数,但它们的参数类型不同,因此它们可以被重载。

  • 操作符重载(本例中使用
    操作符重载允许程序员为用户定义的类型(如类或结构体)指定如何使用标准的操作符(如 +, -, *, /, ==, != 等)。通过重载操作符,可以使自定义类型像内置类型一样使用这些操作符。
    例如,对于自定义的 complex 类型,可以重载 + 操作符来允许两个复数相加:
complex operator+(const complex& a, const complex& b);
  • 注意事项
    重载的函数或操作符必须在参数列表上有所区别,否则会导致编译错误。
    重载不能改变操作符的优先级、结合性或操作数的数量(对于操作符重载而言)。
    函数重载是通过函数名和参数列表的组合来区分的,而操作符重载是通过操作符和参数列表(通常是操作数的类型)来区分的。

在 C++ 中,函数重载和操作符重载都是广泛使用的特性,它们提高了代码的灵活性和可读性。
重载是面向对象编程(OOP)中的一个重要概念,它使得代码更加灵活和易于维护,同时也提高了代码的可重用性。

1 容器

容器(container)是指一个包含若干元素的对象,因为 Vector 的对象都是容器,所以我们称 Vector 是一种容器类型。Vector 作为 double 的容器具有许多优点:它易于理解,建立有用的不变式,提供了包含边界检查的访问功能,并且提供了 size()以允许我们遍历其元素。
然而,它还是存在一个致命的缺陷:它使用 new 分配了元素但是从来没有释放这些元素。这显然是个糟糕的设计,因为尽管 C++定义了一个垃圾回收的接口,可将未使用用的内存提供给新对象,但 C++并不保证垃圾收集器总是可用的。在某些情况下你不能使用回收功能,而且有的时候出于逻辑或性能的考虑你宁愿使用更精确的资源释放控制。因此,我们迫切需要一种机制以确保构造函数分配的内存一定会被销毁,这种机制就叫做析构函数(destructor):

class Vector
{
private:
	double* elem;//elem指向一个包含sz个double的数组
	int sz;
public:
    //这是 Vector 类的构造函数,它接受一个整数 s 作为参数,表示要创建的 Vector 的大小。
    //构造函数使用成员初始化列表来初始化 elem 和 sz。
    //elem 被初始化为指向一个包含 s 个 double 类型元素的新分配数组的指针,
    //sz 被初始化为 s。在构造函数体内,有一个循环,用于将新分配数组的所有元素初始化为 0。
	Vector(int s) :elem{ new double[s] }, sz{ s }//构造函数:请求资源
	{
		for (int i = 0; i != s; ++i)//初始化元素
		{
			elem[i]= 0;
		}
	}
	~Vector()//析构函数:释放资源
	{
		delete[]elem;
	}
	//这是一个成员函数声明,表示重载了[] 运算符。
	// 这个运算符重载允许通过索引访问 Vector 中的元素,
	// 并返回一个对相应元素的引用,从而允许对元素进行读写操作。
	double& operator[](int i);
	//这是一个成员函数声明,用于返回 Vector 的大小(即 sz 的值)。
	//const 关键字表示这个函数不会修改对象的任何成员变量。
	int size() const;
};

对于上面的代码你可能会有些困惑,这里做一些扩展来帮助你理解上面的代码:
在C++中,成员函数声明后面可能会跟随一些特定的关键字,这些关键字提供了关于成员函数行为的额外信息。以下是一些常见的关键字及其用途:

  • const:
    1.当const关键字用于成员函数声明之后(但在参数列表之前),它表示该成员函数不会修改任何类的成员变量(即它不会改变对象的状态)。这样的成员函数被称为常量成员函数。
    2.常量成员函数可以访问类的常量成员(即那些也被声明为const的成员变量)。
    3.示例:int getX() const; 表示getX函数不会修改对象的状态。
  • virtual:
    1.virtual关键字用于在基类中声明虚函数,使得派生类可以重写(覆盖)这些函数。虚函数允许程序在运行时根据对象的实际类型来调用正确的函数版本(多态性)。
    2.示例:virtual void draw() const; 表示draw是一个虚函数。
  • override(C++11及更高版本):
    1.override关键字用于派生类中,明确指示某个成员函数是重写基类中的虚函数。这有助于编译器检查函数签名的匹配性,防止因签名不匹配而导致的潜在错误。
    2.示例:void draw() const override; 表示draw函数重写了基类中的虚函数。
  • final(C++11及更高版本):
    1.当final关键字用于类声明之后,它表示该类不能被继承。
    2.当final关键字用于虚函数声明之后,它表示该函数在派生类中不能被重写。
    3.示例:virtual void show() const final; 表示show函数是最终的,不能在派生类中被重写。
  • noexcept(C++11及更高版本):
    1.noexcept关键字用于指示函数不会抛出异常。这有助于编译器进行更高效的异常处理,并允许某些标准库函数在知道函数不会抛出异常的情况下进行更安全的操作。
    2.示例:void doSomething() noexcept; 表示doSomething函数不会抛出异常。
  • & 和 &&(C++11的右值引用):
    1.当成员函数接受右值引用参数时(使用&&),它表示该函数可以接受临时对象或即将被销毁的对象作为参数。这通常用于实现移动语义,以提高资源管理的效率。
    2.示例:void setData(std::string&& newData); 表示setData函数可以接受一个右值引用的std::string对象。
  • = default 和 = delete(C++11及更高版本):
    1.= default用于显式地指示编译器使用默认的构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符实现。
    2.= delete用于显式地禁止某些特殊成员函数的生成,使它们不可调用。
    3.示例:Point(Point&&) = default; 表示使用默认的移动构造函数。Point(const Point&) = delete; 表示禁止拷贝构造函数。

这些关键字在成员函数声明中的使用提供了关于函数行为、异常安全性、可继承性和可重写性的重要信息,有助于编写更健壮、更清晰和更易于维护的代码。

array new一定要搭配array delete:
关于动态申请的内存,分两种情况:基本数据类型的分配和自定义数据类型的分配。两者不同。
1、基本数据类型
对于基本数据类型,假如有如下代码
int *a = new int[10];
delete a; // 方式1
delete [ ] a; //方式2
肯定会不少人认为方式1存在内存泄露,然而事实上是不会!针对简单的基本数据类型,方式1和方式2均可正常工作,因为:基本的数据类型对象没有析构函数,并且new 在分配内存时会记录分配的空间大小,则delete时能正确释放内存,无需调用析构函数释放其余指针。因此两种方式均可。
2、自定义数据类型
这里一般指类,假设通过new申请了一个对象数组,注意是对象数组,返回一个指针,对于此对象数组的内存释放,需要做两件事情:一是释放最初申请的那部分空间,二是调用析构函数完成清理工作。对于内存空间的清理,由于申请时记录了其大小,因此无论使用delete还是delete[ ]都能将这片空间完整释放,而问题就出在析构函数的调用上,当使用delete时,仅仅调用了对象数组中第一个对象的析构函数,而使用delete [ ]的话,将会逐个调用析构函数:
new double[s];
delete[]elem;

析构函数的命名规则是一个求补运算符~后接类的名字,从含义上来说它是构造函数的补充。Vector 的构造函数使用 new 运算符从自由存储(也称为堆或动态存储)分配一些内存空间,析构函数则使用 delete 运算符释放该空间以达到清理资源的目的。这一切都无须 Vector 的使用者干预,他们只需要像使用普通的内置类型变量那样使用 Vector 对象就可以了。例如:

void fct(int n)
{
	Vector v(n);//在此使用v

	{
		Vector v2(2 * n);//在此使用v2
	}//v2在此处被销毁

}//v1在此处被销毁

Vector 与 int 和 char 等内置类型遵循同样的命名、作用域、分配空间、生命周期等规则。另外出于简化的考虑,Vector 没有涉及错误处理。
构造函数/析构函数的机制是很多优雅技术的基础,尤其是大多数 C++通用资源管理技术的基础。以下是一个 Vector 的图示:
C++程序设计语言笔记——引言:第三章 C++概览之抽象机制1_第1张图片
构造函数负责分配元素空间并正确地初始化 Vector 成员,析构函数则负责释放空间。这就是所谓的数据句柄模型(handle-to-data model),常用来管理在对象生命周期中大小会发生变化的数据。在构造函数中请求资源,然后在析构函数中释放它们的技术称为资源获取即初始化(Resource Acquisition Is Initialization),简称 RAⅡ,它使得我们得以规避“裸 new 操作’”
的风险;换句话说,该技术可以避免在普通代码中分配内存,而是把分配操作隐藏在行为良好的抽象的实现内部。同样,也应该避免“裸 delete 操作”。避免裸 new 和裸 delete 可以使我们的代码远离各种潜在风险,避免资源泄漏。

2 初始化容器

容器的作用是保存元素,因此我们需要找到一种便利的方式将元素存入容器中。为了做到这一点,一种可能的方式是先用若干元素创建一个Vector,然后再依次为这些元素赋值。显然这不是最好的办法,下面列举两种更简洁的途径。
●初始化器列表构造函数(Initializer-list constructor):使用元素的列表进行初始化;
● push_back():在序列的末尾添加一个新元素。
它们的声明形式如下所示:

class Vector
{
public:
	Vector(std::initializer_list<double>);//使用一个列表进行初始化
	void push_back(double);//在末尾添加一个元素,容器的长度加1
};

其中,push_back()可用于添加任意数量的元素。例如:

	Vector read(istream& is)
	{
		Vector v;
		for (double d; is >> d;)//将浮点值读入d
		{
			v.push_back(d);//把d加到v当中
		}
		return v;
	}

上面的循环负责执行输入操作,它的终止条件是遇到文件末尾或者格式错误。在此之前,每个读入的数依次添加到Vector的尾部,最后v的大小就是读取的元素数量。我们使用了一个for语句而不是while语句以便将d的作用域限制在循环内部。
用于定义初始化器列表构造函数的std:initializer_list是一种标准库类型,编译器可以辨识它:当我们使用&列表时,如(1,2,3,4),编译器会创建一个initializer_list类型的对象并将其提供给程序。因此,我们可以书写:

Vector v1 = {1,2,3,4,5};//v1包含5个元素
Vector v2 = {1.23,3.45,6.7,8};//v2包含4个元素

Vector的初始化器列表构造函数可以定义成如下的形式:

//用一个列表初始化
Vector::Vector(std::initializer_list<double> lst):elem{new double[lst.size()]},sz{lst.size()}
{
      copy(lst.begin(),lst.end(),elem);//从lst复制内容到elem中
}

这里我们需要更细致的解释以下列表初始化这个概念,列表初始化是C++11引入的新特性(在C++之前是没有这个概念的),以前初始化一个vector需要这样:

std::vector v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);

而现在c++11添加了initializer_list后,我们可以这样初始化:

std::vector v = { 1, 2, 3, 4 };

并且,C++11允许构造函数和其他函数把初始化列表当做参数:

#include <iostream>
#include <vector>

class MyNumber
{
public:
    MyNumber(const std::initializer_list<int> &v) 
    {
        for (auto itm : v) 
        {
            mVec.push_back(itm);
        }
    }

    void print()
     {
    for (auto itm : mVec)
     {
        std::cout << itm << " ";
    }
  }
private:
    std::vector<int> mVec;
};

int main()
{
    MyNumber m = { 1, 2, 3, 4 };
    m.print();  // 1 2 3 4

    return 0;
}

这里需要更细致的解释 initializer_list这个模板,与 vector 相比,initializer_list 的最大优势在于,它允许您就地指定特定的元素序列,而无需专门处理来创建该列表。我们就不用多次调用 push_back(或 for 循环)来初始化vector实际上,vector 本身有一个构造函数,它接受一个 initializer_list 以便更方便地初始化。 下面显示的就是使用initializer_list进行初始化// v 是通过在输入中传递一个 initializer_list 来构造的;

std::vector<std::string> v = {"hello", "cruel", "world"};

An object of type is a lightweight proxy object that provides access to an array of objects of type const T (that may be allocated in read-only memory). std::initializer_list
A object is automatically constructed when: std::initializer_list
a brace-enclosed initializer list is used to list-initialize an object, where the corresponding constructor accepts an parameter, std::initializer_list
a brace-enclosed initializer list is used as the right operand of assignment or as a function call argument, and the corresponding assignment operator/function accepts an parameter, std::initializer_list
a brace-enclosed initializer list is bound to auto, including in a ranged for loop.
std::initializer_list may be implemented as a pair of pointers or pointer and length. Copying a does not copy the backing array of the corresponding initializer list. std::initializer_list
The program is ill-formed if an explicit or partial specialization of is declared. std::initializer_list
https://en.cppreference.com/w/cpp/utility/initializer_list

通过上面引用的文档可以理解,在以下情况下,将自动构造对象:std::initializer_list:

  • 列表初始化:当使用大括号 {} 括起来的初始化器列表来初始化一个对象时,如果这个对象有一个接受
    std::initializer_list 作为参数的构造函数,那么这个构造函数会被调用。
  • 赋值操作:当使用大括号 {} 括起来的初始化器列表作为赋值操作的右操作数,或者作为函数调用的参数时,如果这个赋值运算符或函数接受一个 std::initializer_list 作为参数,那么这个运算符或函数会被调用。
  • 类型推导:当使用 auto 关键字进行类型推导,并且初始化器是一个用大括号 {} 括起来的列表时,这个列表会被视为 std::initializer_list 类型。这在范围 for 循环中也是适用的。
    下面是一个代码示例,展示了上述三种情况:
#include <iostream>
#include <vector>
#include <initializer_list>
 
// 1. 列表初始化
class MyClass {
public:
    MyClass(std::initializer_list<int> list) {
        for (int value : list) {
            std::cout << value << " ";
        }
        std::cout << std::endl;
    }
};
 
// 2. 赋值操作
class MyContainer {
public:
    void assign(std::initializer_list<int> list) {
        for (int value : list) {
            data.push_back(value);
        }
    }
    
    void print() const {
        for (int value : data) {
            std::cout << value << " ";
        }
        std::cout << std::endl;
    }
    
private:
    std::vector<int> data;
};
 
int main() {
    // 列表初始化
    MyClass obj1 = {1, 2, 3, 4, 5};  // 调用 MyClass(std::initializer_list)
 
    // 赋值操作
    MyContainer container;
    container.assign({6, 7, 8, 9, 10});  // 调用 assign(std::initializer_list)
    container.print();
 
    // 类型推导
    auto obj2 = {11, 12, 13};  // obj2 的类型是 std::initializer_list
    // 注意:直接打印 obj2 不会如预期输出列表内容,因为 std::initializer_list 不直接支持迭代输出
    // 下面的代码仅为了展示类型推导,实际上输出可能不是直观的数字列表
    for (auto elem : obj2) {
        std::cout << elem << " ";
    }
    std::cout << std::endl;
 
    // 范围 for 循环中的类型推导
    std::initializer_list<int> list = {14, 15, 16};
    for (auto elem : list) {
        std::cout << elem << " ";
    }
    std::cout << std::endl;
 
    return 0;
}

在这个示例中:

  • MyClass 的构造函数接受一个 std::initializer_list,用于展示列表初始化。
  • MyContainer 的 assign 方法接受一个 std::initializer_list,用于展示赋值操作。
  • auto obj2 = {11, 12, 13}; 展示了类型推导,其中 obj2 的类型是 std::initializer_list。
  • 最后一部分展示了在范围 for 循环中使用 std::initializer_list 的类型推导。

请注意,直接打印 std::initializer_list 对象(如 obj2)并不会输出其内容,因为 std::initializer_list 不直接支持迭代输出。这里只是为了展示类型推导。
C++11扩大了初始化列表的适用范围,使其可用于所有内置类型和用户定义的类型。无论是初始化对象还是某些时候为对象赋新值,都可以使用这样一组由花括号括起来的初始值了。使用初始化列表时,可添加=,也可不添加。

//定义一个变量并初始化
int units_sold=0;
int units_sold(0);
int units_sold={0};  //列表初始化
int units_sold{0};    //列表初始化

当初始化列表用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化值存在丢失信息的风险,则编译器将报错:

long double ld=3.1415926536;
int a={ld},b={ld}; //错误:转换未执行,因为存在丢失信息的风险
int c(ld),d=ld;   //正确:转换执行,且确实丢失了部分值

聚合类型可以进行直接列表初始化,聚合类型需要满足下列条件:

  • 类型是一个普通数组,如int[5],char[],double[]等
  • 类型是一个类,且满足以下条件:
  • 没有用户声明的构造函数
  • 没有用户提供的构造函数(允许显示预置或弃置的构造函数)
  • 没有私有或保护的非静态数据成员
  • 没有基类
  • 没有虚函数
  • 没有{}和=直接初始化的非静态数据成员
  • 没有默认成员初始化器
struct A {
    int a;
    int b;
    int c;
    A(int, int){}
};
int main() {
    A a{1, 2, 3};// error,A有自定义的构造函数,不能列表初始化
}

上述代码类A不是聚合类型,无法进行列表初始化,必须以自定义的构造函数来构造对象。

struct A {
    int a;
    int b;
    virtual void func() {} // 含有虚函数,不是聚合类
};

struct Base {};
struct B : public Base { // 有基类,不是聚合类
      int a;
    int b;
};

struct C {
    int a;
    int b = 10; // 有等号初始化,不是聚合类
};

struct D {
    int a;
    int b;
private:
    int c; // 含有私有的非静态数据成员,不是聚合类
};

struct E {
      int a;
    int b;
    E() : a(0), b(0) {} // 含有默认成员初始化器,不是聚合类
};

上面列举了一些不是聚合类的例子,对于一个聚合类型,使用列表初始化相当于对其中的每个元素分别赋值;对于非聚合类型,需要先自定义一个对应的构造函数,此时列表初始化将调用相应的构造函数。
列表初始化的好处如下:

  • 方便,且基本上可以替代括号初始化
  • 可以使用初始化列表接受任意长度
  • 可以防止类型窄化,避免精度丢失的隐式类型转换

这里扩展一下类型窄化,类型窄化是列表初始化通过禁止下列转换,对隐式转化加以限制:

  • 从浮点类型到整数类型的转换
  • 从 long double 到 double 或 float 的转换,以及从 double 到 float 的转换,除非源是常量表达式且不发生溢出
  • 从整数类型到浮点类型的转换,除非源是其值能完全存储于目标类型的常量表达式
  • 从整数或无作用域枚举类型到不能表示原类型所有值的整数类型的转换,除非源是其值能完全存储于目标类型的常量表达式

示例:

int main() {
    int a = 1.2; // ok
    int b = {1.2}; // error

    float c = 1e70; // ok
    float d = {1e70}; // error

    float e = (unsigned long long)-1; // ok
    float f = {(unsigned long long)-1}; // error
    float g = (unsigned long long)1; // ok
    float h = {(unsigned long long)1}; // ok

    const int i = 1000;
    const int j = 2;
    char k = i; // ok
    char l = {i}; // error

    char m = j; // ok
    char m = {j}; // ok,因为是const类型,这里如果去掉const属性,也会报错
}

下面再扩展一下迭代器的概念:
迭代器(Iterator)是一种设计模式,它提供了一种方法顺序访问一个集合对象中的各个元素,而不需要暴露该对象的内部表示。迭代器模式属于行为型模式,它使得集合对象可以通过一个统一的接口被遍历,而无需知道集合对象的内部具体实现。
在C++中,迭代器是一个非常重要的概念,它被广泛用于STL(Standard Template Library,标准模板库)中。C++ STL提供了大量的容器(如vector、list、map等),这些容器都支持迭代器接口,使得我们可以使用相同的代码来遍历不同的容器。
迭代器通常具有以下特性:

  • 迭代器指向容器中的元素:迭代器可以看作是一个指针,它指向容器中的某个元素。通过迭代器,我们可以访问或修改该元素的值。
  • 迭代器提供遍历容器的方法:迭代器提供了如++(前进到下一个元素)、–(回退到上一个元素)、*(解引用,获取当前元素的值)、->(获取当前元素的成员)等操作符,使得我们可以方便地遍历容器中的元素。
  • 迭代器类型:C++ STL中的迭代器有多种类型,包括输入迭代器、输出迭代器、前向迭代器、双向迭代器和随机访问迭代器。不同类型的迭代器提供了不同级别的功能。例如,随机访问迭代器支持高效的元素访问(如通过下标访问),而输入迭代器则只保证可以顺序读取元素。
  • 迭代器失效:在某些情况下,迭代器可能会失效。例如,当容器被修改(如添加或删除元素)时,指向该容器的迭代器可能会变得无效。因此,在使用迭代器时,需要注意迭代器的有效性。

下面是一个简单的C++代码示例,展示了如何使用迭代器来遍历一个std::vector容器:

#include <iostream>
#include <vector>
 
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
 
    // 使用迭代器遍历vector
    for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it) {
        std::cout << *it << " ";
    }
    std::cout << std::endl;
 
    return 0;
}

在这个示例中,vec.begin()返回一个指向vec第一个元素的迭代器,vec.end()返回一个指向vec最后一个元素之后位置的迭代器(注意,这个迭代器不指向任何有效的元素,仅用作遍历的结束标志)。然后,我们使用一个for循环来遍历这个迭代器范围,并打印出每个元素的值。

总结

由于篇幅原因,作者将在下一篇文章中讲解类的抽象类型。最优引用下面这段文字来讲解c++ 为什么在参数中使用initializer_list而不是vector这个问题:

It is a sort of contract between the programmer and the compiler. The programmer says {1,2,3,4}, and the compiler creates an object of type initializer_list out of it, containing the same sequence of elements in it. This contract is a requirement imposed by the language specification on the compiler implementation.
That means, it is not the programmer who creates manually such anobject but it is the compiler which creates the object, and pass thatobject to function which takes initializer_list as argument.
The std::vector implementation takes advantage of this contract, andtherefore it defines a constructor which takes initializer_list asargument, so that it could initialize itself with the elements in theinitializer-list.
Now suppose for a while that the std::vector doesn’t have anyconstructor that takes std::initializer_list as argument, then youwould get this:
void f(std::initializer_list const &items);
void g(std::vector const &items);
f({1,2,3,4}); //okay
g({1,2,3,4}); //error (as per the assumption)
As per the assumption, since std::vector doesn’t have constructor thattakes std::initializer_list as argument, which implies you cannotpass {1,2,3,4} as argument to g() as shown above, because the compilercannot create an instance of std::vector out of the expression{1,2,3,4} directly. It is because no such contract is ever made
between programmer and the compiler, and imposed by the language. Itis through std::initializer_list, the std::vector is able to createitself out of expression {1,2,3,4}.
Now you will understand that std::initializer_list can be usedwherever you need an expression of the form of {value1, value2, …,valueN}. It is why other containers from the Standard library also define constructor that takes std::initializer_list as argument. In this way, no container depends on any other container for construction
from expressions of the form of {value1, value2, …, valueN}.Hope that helps.

翻译如下:
这是程序员与编译器之间的一种约定。程序员写出{1,2,3,4},编译器则根据这个序列创建一个initializer_list类型的对象,其中包含了相同的元素序列。这个约定是语言规范对编译器实现所提出的要求。
这意味着,不是程序员手动创建这样的对象,而是编译器创建这个对象,并将其传递给以initializer_list作为参数的函数。
std::vector的实现利用了这个约定,因此它定义了一个接受initializer_list作为参数的构造函数,以便能够使用初始化列表中的元素来初始化自身。
现在,假设std::vector没有任何接受std::initializer_list作为参数的构造函数,那么你会遇到这样的情况:

void f(std::initializer_list const &items);
void g(std::vector const &items);
f({1,2,3,4}); // 可以
g({1,2,3,4}); // 错误(根据假设)

根据假设,由于std::vector没有接受std::initializer_list作为参数的构造函数,这意味着你不能像上面那样将{1,2,3,4}作为参数传递给g()函数,因为编译器无法直接从表达式{1,2,3,4}创建std::vector的实例。这是因为程序员与编译器之间从未建立过这样的约定,并且语言规范也没有强制要求这样做。正是通过std::initializer_list,std::vector才能够从表达式{1,2,3,4}中创建自身。
现在你会明白,std::initializer_list可以在你需要形如{value1, value2, …, valueN}的表达式时使用。这就是为什么标准库中的其他容器也定义了接受std::initializer_list作为参数的构造函数。这样,任何容器都不依赖于其他容器来从形如{value1, value2, …, valueN}的表达式中构造自身。希望这能帮助你理解。

你可能感兴趣的:(C++笔记,c++,笔记,经验分享)