第十二章 类
在 C++ 中,用类来定义自己的抽象数据类型。
12.1. 类的定义和声明
12.1.1. 类定义:扼要重述
最简单地说,类就是定义了一个新的类型和一个新作用域。
类成员
类成员可以是属性、方法或类型别名。
成员可以是公有(public)、私有(private)或受保护(protected)。
公有成员可从类的外部访问,私有成员仅能在类内部访问,受保护成员在继承体系中有特殊访问权限。
构造函数
构造函数是特殊的成员函数,与类同名,用于初始化新创建的对象。
构造函数可以有参数,也可以没有。默认构造函数无参数。
通常使用构造函数初始化列表来初始化成员变量。
每个类至少需要一个构造函数,如果不显式定义,编译器会提供一个默认的无参构造函数。
成员函数
成员函数是在类内部声明的函数,用于访问和修改对象的状态。
成员函数可以在类内部定义(隐式内联),也可以在类外部定义(需使用作用域操作符)。
成员函数的参数隐含地接收一个this指针,能够访问和操作上一级作用域所属对象的成员。
常量成员函数通过在参数列表后添加const关键字声明,保证不会修改对象的任何成员变量。
double avg_price() const;
12.1.2. 数据抽象和封装
类背后的核心理念是数据抽象和封装。
数据抽象意味着通过接口(即类对外提供的功能)和实现(即类内部如何完成这些功能)的分离,来隐藏其实现。程序员使用类时,只需关注其接口,而无需深入了解其内部实现细节。
封装则是将低层次的元素(如数据和函数)组合成高层次的实体(如类),隐藏其内部实现,仅通过公共接口与外部交互。
在C++中,这些概念通过访问标号(如public、private)来实现。
public成员定义了类的抽象接口,任何外部代码都可以访问它们。
private成员则封装了类的内部实现细节,仅类内部或友元可以访问。默认情况下,struct定义的类成员默认为public,而class定义的类成员默认为private。
简而言之,类通过数据抽象和封装提供了代码的模块化和重用性,使得程序员能够构建复杂但易于理解和维护的系统。
12.1.3. 关于类定义的更多内容
在C++中,类可以包含多个同类型的数据成员,这些成员可以在单个声明中指定。
为了简化类的设计,可以使用类型别名(typedef或using)来隐藏实现细节,使类的接口更清晰。此外,类成员函数可以被重载,即同一类中可以存在多个同名函数,只要它们的参数列表不同。重载成员函数的调用遵循与非成员函数相同的函数匹配规则。
在类定义内部直接定义的成员函数自动被视为内联函数,但也可以显式地在函数声明或定义前加上inline关键字。inline关键字用于建议编译器尝试将函数体在调用点直接展开,以减少函数调用的开销。但是否真正内联由编译器决定。
对于在类外定义的成员函数,如果希望在声明和定义时都明确标记为inline,可以在两处都使用inline关键字。这样做有助于保持代码的可读性和一致性。注意,inline成员函数的定义必须在每个调用该函数的源文件中可见,因此通常将定义放在头文件中,或与类定义相同的头文件中。
12.1.4. 类声明与类定义
在C++中,类的定义在右花括号(})处结束,这标志着类的所有成员(包括数据成员和成员函数)的完整说明。一旦定义了类,其成员和所需存储空间就确定了。在单个源文件中,类只能被定义一次,而在多个文件中定义类时,这些定义必须完全一致。通常,将类定义放在头文件中,并使用头文件保护符(或预处理指令如#pragma once)来防止头文件内容被重复包含。
类可以仅被声明而不定义,这种声明称为前向声明。前向声明允许在程序中提前引入类类型,但此时该类是一个不完全类型,即知道它是个类型但不知道具体包含哪些成员。不完全类型只能用于有限的目的,如定义指向该类型的指针或引用,或声明该类型作为参数或返回类型的函数。
在创建类的对象或访问其成员之前,必须完整地定义该类,以便编译器能够为其对象分配适当的存储空间。类的成员如果是指向类自身的指针或引用,则可以在类定义中使用,因为此时类名已经声明。然而,类不能包含自身类型的数据成员,因为这将导致无限递归的存储空间需求,但可以通过指针或引用来实现类似的逻辑结构。
前向声明常用于处理类之间的相互依赖关系,允许在完全定义某个类之前引用它。这在编写复杂系统时非常有用,可以模块化地构建程序,减少编译依赖和编译时间。
12.1.5. 类对象
在C++中,定义一个类实际上是在定义一个新的类型和新的命名空间。这个类型描述了对象的结构和行为,但它本身并不直接分配存储空间。相反,当定义这个类型的对象时,编译器会为每个对象分配足够的存储空间来存储其所有成员。
类定义完成后,可以以两种主要方式使用它:
直接使用类名作为类型名来定义对象,这是C++引入的更简洁的方式。
使用class或struct关键字后跟类名来定义对象,这种方式是从C语言继承而来的,在C++中仍然有效,但通常不推荐,因为它不如第一种方式直观。
class Sales_item { public: ...private: std::string isbn; unsigned units_sold; double revenue;
};
类定义以分号结束是必需的,原因是在类定义之后,可以紧接着定义该类型的对象。分号标志着类定义的结束,使得编译器能够区分类定义和随后的任何对象定义或其他语句。
最佳实践是将类定义放在一个或多个头文件中,并在需要时包含这些头文件来定义对象。
12.2. 隐含的 this 指针
在 C++中,成员函数自动获得一个名为 this 的隐含指针参数,该指针指向调用它的对象。
this 指针使得成员函数能够访问和修改对象的数据成员。
12.2.1. 何时使用this指针
在成员函数中,如果需要返回调用该函数的对象的引用,以便可以链式调用成员函数,就必须使用 return *this;。
当成员函数的参数名或局部变量名与类的成员名相同时,可以使用 this->成员名来明确指定是类的成员。
12.2.2. 成员函数与 this指针的返回类型
非const成员函数:
返回类型为类的引用(如Screen&),允许链式调用。
const成员函数:
由于 const成员函数承诺不会修改对象的状态,它们应该返回指向const对象的引用(如const Screen&),以防止通过返回的引用修改对象。
12.2.3. const重载
有时,一个成员函数可能需要根据对象是否为 const来表现出不同的行为。此时,可以重载该函数,一个为 const版本,另一个为非 const版本。
非 const版本可以修改对象的状态,而 const版本则不能。
12.2.4. 可变数据成员(mutable)
当需要在 const 成员函数中修改某个数据成员时,可以将该成员声明为 mutable。
mutable 成员即使在 const对象中也可以被修改。
class Screen {
public: Screen& display(std::ostream &os) { do_display(os); return *this; // 返回对调用对象的引用 } const Screen& display(std::ostream &os) const { do_display(os); return *this; // 在const成员函数中,返回const引用 } // 其他成员函数... private: mutable size_t access_ctr; // 可变数据成员,即使在const成员函数中也可以修改 void do_display(std::ostream &os) const { ++access_ctr; // 修改可变数据成员 os << contents; // 假设contents是存储屏幕内容的成员 } // 其他数据成员和成员函数...
};
12.3. 类作用域
类的作用域:
每个类都定义了自己的作用域,其成员(包括数据成员和函数成员)只能在该类的作用域内被直接访问。
唯一类型:
即使两个类具有完全相同的成员列表,它们也被视为不同的类型。
访问成员:
类外部访问类的成员(除静态成员外)必须通过类对象或类对象指针,使用 . 或 -> 操作符。
在类外部定义成员
成员定义:
成员在类外部定义,需指明是哪个类的成员,使用类名::成员名的形式。:: 域操作符
形参表和函数体:
定义在类外部的成员函数时,可以直接引用类中的其他成员(无需使用this->)。
返回类型:
返回类型出现在成员名字之前,需使用完全限定名(类名::类型名)。
12.3.1. 类作用域中的名字查找
局部作用域优先:
在函数体内,首先查找当前局部作用域(如函数内部声明的局部变量)中的名字。
类作用域:
如果局部作用域中未找到,则在类作用域中查找,包括类的成员变量和成员函数声明。注意,成员函数的定义体虽然位于类外部,但在编译时仍视为在类作用域内。
外围作用域:
如果类作用域中也未找到,则在外围作用域(如包含类定义的全局作用域)中查找。这包括在类定义之前声明的全局变量和函数。
注意事项,
遮蔽:
如果局部作用域中的名字与类成员或其他作用域中的名字相同,则局部作用域中的名字会遮蔽其他同名名字。
this指针和类名限定:
可以使用this->成员名或类名::成员名来显式引用类成员,以避免遮蔽。
声明顺序:
在类定义中,成员必须在使用之前声明。一旦某个名字被用作类型名,则不能在同一作用域内重新定义。
12.4. 构造函数
构造函数是类中的一种特殊成员函数,用于在创建对象时初始化对象的成员变量。
每个类至少有一个构造函数,且构造函数的名字与类名相同,不返回任何类型(连void也不返回)。构造函数的主要作用是确保对象在创建时具有合适的初始状态。
在C++中,构造函数可以通过多种方式被定义,包括使用构造函数初始化列表来直接初始化成员变量,或者通过函数体中的代码来设置成员变量的值。如果成员变量是内置类型或复合类型,并且没有在构造函数初始化列表中显式初始化,那么它们将被自动初始化为不确定的值(对于内置类型)或类型的默认构造函数(对于复合类型)。
构造函数可以被重载,这意味着一个类可以有多个构造函数,只要它们的参数列表不同。这允许程序员以不同的方式初始化对象。例如,一个构造函数可能只接受一个字符串参数来初始化ISBN,而另一个构造函数可能接受一个输入流(如std::istream)来从文件中读取数据。
当创建类的实例时,编译器会根据提供的实参类型和数量来决定调用哪个构造函数。如果没有提供任何实参,则调用默认构造函数。
构造函数在对象创建时自动执行,无论是通过直接声明变量、使用 new操作符动态分配内存,还是作为函数返回值返回对象时。对于 const对象,构造函数也是必需的,但构造函数不能声明为const,因为构造函数的任务是初始化对象,而const成员函数承诺不修改对象的状态。
12.4.1. 构造函数初始化式
在C++中,构造函数是特殊的成员函数,用于在创建对象时初始化其成员变量。
构造函数可以包含初始化列表,这是初始化成员变量的推荐方式,特别是对于那些没有默认构造函数的类类型成员、const成员或引用成员。
构造函数初始化列表
构造函数初始化列表紧跟在构造函数参数列表之后,以冒号:开始,后跟成员变量名和用于初始化这些成员变量的表达式(放在圆括号中),多个成员变量之间用逗号 ,分隔。
对于没有默认构造函数的类类型成员,必须在初始化列表中初始化。
对于const成员和引用成员,也必须在初始化列表中初始化。
初始化列表通常比在构造函数体内赋值更高效,因为它直接调用成员的构造函数或赋值操作,避免了先默认构造再赋值的开销。
//类定义
class Sales_item {
public: //构造函数初始化列表,类内定义Sales_item(): units_sold(0), revenue(0.0) { }
private: std::string isbn; unsigned units_sold; double revenue; };
//构造函数初始化列表,类外定义
Sales_item::Sales_item(const string &book) : isbn(book), units_sold(0), revenue(0.0) { }
关于成员初始化的次序,有几点关键信息需要明确:
初始化次序与声明次序一致:
不论构造函数初始化列表中成员变量的列出顺序如何,成员变量总是按照它们在类中声明的顺序进行初始化。
初始化式的复杂性:
构造函数初始化列表中的初始化式可以是任意复杂的表达式,包括使用其他形参进行计算。这提供了极大的灵活性来根据输入参数初始化成员变量。
使用默认构造函数和特殊值:
如果类类型的成员变量有默认构造函数,并且你希望使用非默认的值进行初始化,你可以使用成员类型的特殊构造函数来生成所需的默认值。
12.4.2. 默认实参与构造函数
来看看默认构造函数和接受一个 string 的构造函数的定义。
//接受一个 string的构造函数
Sales_item(const std::string &book): isbn(book), units_sold(0), revenue(0.0) { } //默认构造函数,给了初始化列表
Sales_item(): units_sold(0), revenue(0.0) { }
当创建 Sales_item 对象时,如果未提供 string 参数,则 isbn 会被初始化为空字符串;
如果提供了 string 参数,则 isbn 会被初始化为该参数的值。
无论哪种情况,units_sold 和 revenue 都会分别被初始化为 0 和 0.0。
12.4.3. 默认构造函数
在C++中,如果一个类没有定义任何构造函数,编译器会自动生成一个默认构造函数(也称为合成的默认构造函数)。然而,一旦类定义了任何构造函数(无论是否带有默认参数),编译器就不会再自动生成默认构造函数。
如果一个构造函数为所有形参提供了默认实参,那么这个构造函数就可以作为默认构造函数使用,因为它允许在创建对象时不提供任何参数。
合成的默认构造函数的行为是,
对于类类型的成员,它会调用这些成员的默认构造函数进行初始化;
对于内置类型(如int、指针等)和复合类型(如数组)的成员,如果它们是在全局作用域中定义的,则会被初始化(例如,全局指针会被初始化为nullptr)。
如果是在局部作用域中定义的,则不会被初始化,其值是未定义的。
如果类包含内置或复合类型的成员,并且没有显式定义构造函数来初始化这些成员,那么这些成员将处于未定义状态,这可能导致程序错误。因此,类通常应该定义自己的构造函数来确保所有成员都被正确初始化。
默认构造函数的重要性体现在多个方面:
成员初始化:
如果类包含其他类类型的成员,而这些成员类没有默认构造函数,则必须在包含类的构造函数中显式初始化这些成员。
容器和数组:
如果类对象用作容器(如std::vector)的元素或动态/静态分配数组的元素,并且没有默认构造函数,则无法直接创建这样的容器或数组,除非显式提供元素初始化。
隐式使用:
在某些情况下,编译器会隐式地尝试调用默认构造函数,
使用默认构造函数的注意事项:
避免将使用默认构造函数的对象声明误写为函数声明,
//Sales_item是一个类
Sales_item myobj;//定义一个类变量
可以使用值初始化语法来显式调用默认构造函数并初始化对象。
Sales_item myobj = Sales_item();//使用值初始化语法显式创建并初始化一个类对象,编译器会调用构造函数
12.4.4. 隐式类类型转换
构造函数除了用于初始化类的对象外,还可以定义从其他类型到类类型的隐式转换。
如果一个构造函数只接受一个参数(或者所有参数都有默认值,但只有一个参数是非默认的),那么它可以被用于隐式转换。
比如一个point对象,有 int x、int y两个成员变量,构造函数只给了x的默认参数,没给y的。
如果有一个函数,使用 这个point对象作为参数,传了个5,编译器会调用构造函数把这个5当参数,得到一个 point对象出来,给函数用。就造成了隐式类类型转换。
为了抑制这种隐式转换,可以将构造函数声明为 explicit。
explicit 关键字只能用于类内部的构造函数声明中,它告诉编译器这个构造函数只能用于直接初始化,不能用于隐式转换。
除非有明确的需求,否则应该将单参数构造函数声明为explicit,以避免潜在的错误和意外的行为。这样做可以提高代码的可读性和可维护性,同时允许在需要时显式地进行类型转换。
#include <iostream> class Point {
public: int x, y; // 构造函数,接受一个整数作为x坐标,y坐标默认为0 Point(int x_val) : x(x_val), y(0) {} // 为了展示Point对象,我们重载了<<运算符 friend std::ostream& operator<<(std::ostream& os, const Point& p) { return os << "(" << p.x << ", " << p.y << ")"; }
}; // 一个函数,它接受一个Point对象作为参数
void printPoint(const Point& p) { std::cout << "Point: " << p << std::endl;
} int main() { // 在这里,整数5被隐式转换为Point对象,其中x=5, y=0 printPoint(5); // 隐式转换发生在这里 return 0;
}
#include <iostream> class Point {
public: int x, y; // 构造函数,现在被声明为explicit,阻止隐式转换 explicit Point(int x_val) : x(x_val), y(0) {} // 为了展示Point对象,我们重载了<<运算符 friend std::ostream& operator<<(std::ostream& os, const Point& p) { return os << "(" << p.x << ", " << p.y << ")"; }
}; // 一个函数,它接受一个Point对象作为参数
void printPoint(const Point& p) { std::cout << "Point: " << p << std::endl;
} int main() { // 尝试隐式转换,但由于构造函数是explicit的,这将导致编译错误 // printPoint(5); // 这行代码现在会编译失败 // 显式地创建一个Point对象并传递给printPoint printPoint(Point(5)); // 正确,因为我们是显式地创建了Point对象 return 0;
}
12.4.5. 类成员的显式初始化
C++ 允许直接初始化结构体(或类)的成员,如果它们没有定义构造函数且成员都是 public的,可以通过与初始化数组类似的语法来实现,即{成员1的值, 成员2的值, ...}。这种初始化方式要求严格按照成员在结构体中声明的顺序进行。
这违反了封装原则,限制了类的灵活性和安全性。
相比之下,定义和使用构造函数是一种更优的做法。
12.5. 友元
C++ 的友元机制允许非成员函数或类访问其他类的私有成员,通过 friend关键字声明在类内部,不受访问控制符限制,常用于重载操作符等场景,建议成组放置以增强代码可读性。
友元类
当一个类(如Window_Mgr)被声明为另一个类(如Screen)的友元时,Window_Mgr的所有成员函数都可以访问Screen的私有和保护成员。
友元关系是单向的,不可传递的,也不受访问控制符(如public、private)的约束。
友元成员函数
除了整个类,也可以将特定的成员函数(如Window_Mgr::relocate)声明为友元。这样,只有指定的成员函数能访问另一个类的私有成员。
声明友元成员函数时,需要使用该函数所属的类名和函数签名进行限定。
重载函数与友元关系
如果一个类有多个重载版本的函数,并且希望将其中某个特定版本设为友元,那么需要明确声明那个具体的函数版本。
类不会自动将重载函数集中的所有版本都视为友元,除非每个版本都被单独声明为友元。
注意事项
使用友元时需要谨慎,因为它破坏了封装性,使得类的内部实现细节暴露给外部。
#include <iostream> // Circle 类
class Circle {
private: double radius; // 私有成员变量 public: // 构造函数 Circle(double r) : radius(r) {} // 设置友元类 friend class Drawer; // Drawer 类可以访问 Circle 的私有成员 // 成员函数,用于展示半径(为了演示) void showRadius() const { std::cout << "Radius: " << radius << std::endl; }
}; // Drawer 类
class Drawer {
public: // 成员函数,用于修改 Circle 对象的半径 void drawCircle(Circle& c, double newRadius) { c.radius = newRadius; // 因为 Drawer 是 Circle 的友元,所以可以访问私有成员 std::cout << "Circle radius has been changed to: " << newRadius << std::endl; }
}; int main() { Circle c(5.0); // 创建一个 Circle 对象,半径为 5.0 c.showRadius(); // 显示半径 Drawer d; // 创建一个 Drawer 对象 d.drawCircle(c, 10.0); // 使用 Drawer 对象修改 Circle 对象的半径 c.showRadius(); // 再次显示半径,以验证修改是否成功 return 0;
}
12.6. static 类成员
在某些情况下,为了访问与特定类所有对象共享的全局信息(如计数、配置或状态),我们需要一种方式来封装这些信息而不破坏类的封装性。
使用类的静态(static)成员可以解决这个问题。
静态成员包括静态数据成员和静态函数成员。静态数据成员属于类本身,不依赖于类的任何特定对象实例,而静态成员函数属于类本身,可以直接访问静态数据成员,但不能直接访问非静态成员。
静态成员可以是私有的,保护了数据的完整性。
在类中定义静态成员时,只需在成员声明前加上static关键字。
静态成员可以像其他成员一样访问,但也可以通过类名和作用域解析运算符(::)直接访问,无需类的实例。
12.6.1. static 成员函数
当在类外部定义静态成员函数时,不需要再次使用static关键字,因为这个关键字仅在类内部声明成员函数时使用。
静态成员函数属于类本身,而不是类的任何对象实例,因此它们没有this指针。这意味着静态成员函数不能直接访问类的非静态成员,因为非静态成员是依赖于对象实例的。
由于静态成员函数不属于任何对象实例,因此它们不能被声明为const,因为const成员函数承诺不会修改调用它的对象实例的状态。
同样地,静态成员函数也不能被声明为虚函数,因为虚函数是基于对象实例的多态性的基础,而静态成员函数不依赖于对象实例。
简而言之,
静态成员函数是属于类而不是属于对象的,
没有this指针,
不能访问非静态成员,
不能被声明为const,
不能被声明为虚函数。
12.6.2. static 数据成员
静态数据成员可以声明为任何类型,包括常量、引用、数组和类类型等。
它们必须在类内部声明,在类外部定义,并在定义时初始化,而不是通过构造函数。
静态成员的定义方式与普通成员类似,但需要指定成员的完全限定名(即类名和成员名)。
静态成员可以在类内部通过类名直接访问。
对于整型常量静态成员(const static),如果其初始化式是常量表达式,则可以在类定义体内部直接初始化。但仍需要在类定义外部进行定义。
静态成员可以具有所属类的类型,非静态成员不能具有所属类的类型。
class MyClass {
public: static MyClass instance; // 静态成员,具有所属类的类型...
静态成员可以用作默认实参,因为它们的值不依赖于任何特定对象。
#include <iostream> class Account {
private: static double totalBalance; // 静态数据成员,表示所有账户的总余额 static double baseRate; // 静态数据成员,表示基础利率 double balance; // 非静态数据成员,表示单个账户的余额 public: Account(double initialBalance) : balance(initialBalance) { // 在构造函数中更新总余额 totalBalance += initialBalance; } ~Account() { // 在析构函数中减少总余额 totalBalance -= balance; } // 静态成员函数,用于设置基础利率 static void setBaseRate(double newRate) { baseRate = newRate; } // 静态成员函数,用于获取基础利率 static double getBaseRate() { return baseRate; } // 非静态成员函数,演示如何访问静态和非静态成员 void deposit(double amount) { balance += amount; // 更新总余额(虽然这通常应该在deposit逻辑之外处理,但为了演示) totalBalance += amount; } // 静态成员函数,用于获取所有账户的总余额 static double getTotalBalance() { return totalBalance; } // 其他成员函数...
}; // 在类定义外部初始化静态数据成员
double Account::totalBalance = 0.0;
double Account::baseRate = 0.05; // 假设初始基础利率为5% int main() { // 创建账户 Account acct1(1000.0); Account acct2(2000.0); // 设置新的基础利率 Account::setBaseRate(0.06); // 输出基础利率 std::cout << "Current base rate: " << Account::getBaseRate() * 100 << "%" << std::endl; // 存款操作(这将自动更新总余额) acct1.deposit(500.0); // 输出所有账户的总余额 std::cout << "Total balance of all accounts: " << Account::getTotalBalance() << std::endl; return 0;
}
第十三章 复制控制
在C++中,每种类型(无论是内置类型还是类类型)都定义了一组操作,这些操作指定了可以对该类型对象执行的任务。
对于类类型,这些操作还包括了对象的创建、复制、赋值和销毁过程,这些过程通过特殊的成员函数来控制:构造函数、复制构造函数、赋值操作符和析构函数。
复制构造函数
复制构造函数接受一个类对象的常量引用作为参数。当使用另一个同类型的对象来初始化新对象时,会调用复制构造函数。此外,在函数参数传递和返回值传递时,如果涉及对象复制,也会隐式调用复制构造函数。
赋值操作符
赋值操作符(=)用于将一个对象的值赋给另一个同类型的对象。如果没有为类显式定义赋值操作符,编译器会合成一个。然而,对于包含动态分配内存(如指针成员)的类,默认的赋值操作符可能不会按预期工作,因为它只会进行浅拷贝。
析构函数
析构函数在对象生命周期结束时自动调用。析构函数用于释放对象在生命周期中分配的资源,如动态分配的内存、文件句柄等。即使类没有显式定义析构函数,编译器也会为类生成一个默认的析构函数,该析构函数会负责销毁类的非静态数据成员。
复制控制
复制构造函数、赋值操作符和析构函数统称为复制控制。这些操作定义了对象在复制、赋值和销毁时的行为。虽然编译器会为类自动生成这些操作的默认版本,但在许多情况下,这些默认行为并不符合类的设计需求,特别是当类包含指针成员时。
指针成员与复制控制
默认的复制构造函数和赋值操作符只会进行浅拷贝,即复制指针的值而不是指针指向的数据。对于包含指针成员的类,通常需要定义自己的复制构造函数、赋值操作符和析构函数,以实现深拷贝和正确的资源管理。
13.1. 复制构造函数
复制构造函数是C++中一种特殊的构造函数,它接受一个对类类型对象的常量引用作为参数,用于根据同类型的另一个对象初始化新对象。可由编译器隐式调用。
复制构造函数的主要用途包括:
对象初始化:无论是显式还是隐式地根据另一个同类型的对象初始化新对象。
函数参数传递:当对象作为函数的实参时,会调用复制构造函数创建一个临时对象副本。
函数返回值:当函数返回类类型对象时,会调用复制构造函数将返回值复制到调用者的上下文中。
容器元素初始化:在初始化顺序容器(如vector)的元素时,如果指定了初始大小,会先调用默认构造函数创建一个临时对象,然后用复制构造函数将该对象复制到容器的每个元素中。
数组元素初始化:在初始化类类型数组时,如果提供了初始化列表,会使用复制初始化来初始化每个元素。
C++支持两种初始化形式:直接初始化和复制初始化。
对于类类型对象,两者在底层实现上存在差异:
直接初始化:使用圆括号( ),直接调用与实参匹配的构造函数。
复制初始化:使用等号=,首先调用适当的构造函数创建一个临时对象,然后用复制构造函数将临时对象的内容复制到目标对象中。
对于不支持复制的类型(如IO流对象),或者构造函数被声明为explicit,复制初始化和直接初始化之间的区别尤为重要。例如,ifstream类不支持复制,因此不能用复制初始化形式来创建ifstream对象。
13.1.1. 合成的复制构造函数
在C++中,如果我们没有为类显式定义复制构造函数,编译器会自动为我们合成一个。
这个合成的复制构造函数会逐个复制对象的非静态成员。
对于内置类型(如int、double等),它会直接复制值;对于类类型成员,它会使用该类的复制构造函数进行复制;对于数组成员,它会逐个复制数组中的每个元素。
13.1.2. 定义自己的复制构造函数
复制构造函数是接受单个类类型常量(const)引用作为参数的构造函数,用于创建当前对象的副本。虽然技术上可以定义接受非const引用的版本,但出于安全和效率考虑,通常使用const引用。此构造函数不应被声明为explicit,因为它常被隐式调用(如按值传递对象或返回对象时)。
#include <iostream> class Point {
public: // 默认构造函数 Point() : x(0), y(0) {} // 带有初始化列表的构造函数 Point(int xVal, int yVal) : x(xVal), y(yVal) {} // 显式定义的复制构造函数 Point(const Point& other) : x(other.x), y(other.y) { // 在这里,我们只是简单地将other对象的x和y成员值复制到新对象中 // 但实际上,复制构造函数中也可以包含更复杂的逻辑,比如深拷贝等 std::cout << "复制构造函数被调用" << std::endl; } // 一个简单的成员函数,用于打印点的坐标 void print() const { std::cout << "(" << x << ", " << y << ")" << std::endl; } private: int x, y; // 成员变量
}; int main() { Point p1(1, 2); // 使用带有初始化列表的构造函数 Point p2 = p1; // 调用复制构造函数来创建p2,作为p1的副本 p1.print(); // 输出: (1, 2) p2.print(); // 输出: (1, 2) return 0;
}
13.1.3. 禁止复制
有些类(如iostream)需要完全禁止复制,以避免不期望的副本行为。虽然省略复制构造函数会让编译器合成一个,但为了真正禁止复制,类应显式将复制构造函数声明为 private。
如果复制构造函数被声明为private,则它只能被该类的成员函数和友元函数访问。
由于类的外部代码不是类的成员函数也不是友元,因此无法调用 private的复制构造函数。
13.2. 赋值操作符
类通过定义赋值操作符(operator=)来控制其对象赋值时的行为。如果类没有显式定义自己的赋值操作符,编译器会合成一个。赋值操作符必须是类的成员函数,它接受一个同类型对象的const 引用作为参数,并返回对当前对象的引用。
重载操作符是特殊函数,其名称以 operator关键字开头,后跟要重载的操作符符号。赋值操作符operator=用于定义对象赋值的行为。
合成赋值操作符与合成复制构造函数的操作类似,它会逐个成员地将右操作数对象的成员值赋给左操作数对象的对应成员。对于数组,会逐个元素赋值。合成赋值操作符返回左操作数对象的引用,以便支持链式赋值。
可以使用合成复制构造函数的类通常也可以使用合成赋值操作符。然而,如果类需要显式定义复制构造函数,那么它也很可能需要定义自己的赋值操作符。复制构造函数和赋值操作符通常被视为一个单元,因为它们的存在和需求往往相互关联。类需要其中一个,很可能也需要另一个。
//赋值操作符重载
#include <iostream> class Point {
private: int x, y; public: // 构造函数 Point(int x = 0, int y = 0) : x(x), y(y) {} // 赋值操作符重载 Point& operator=(const Point& other) { // 自赋值检查(虽然在这个简单的例子中可能不是必需的) if (this != &other) { x = other.x; y = other.y; } // 返回当前对象的引用 return *this; } // 为了方便演示,添加一个打印函数 void print() const { std::cout << "(" << x << ", " << y << ")" << std::endl; }
}; int main() { Point p1(1, 2); Point p2; // 初始状态下打印p1和p2 p1.print(); // 输出: (1, 2) p2.print(); // 输出: (0, 0),因为p2使用了默认构造函数 // 使用赋值操作符 p2 = p1; // 赋值后打印p1和p2 p1.print(); // 输出: (1, 2) p2.print(); // 输出: (1, 2),现在p2的值与p1相同 return 0;
}
13.3. 析构函数
析构函数是一个特殊成员函数,它在对象生命周期结束时自动调用,用于释放或回收对象在构造函数或生命期内获取的资源。
析构函数名是在类名前加波浪号(~),没有返回值和参数,且不能重载。
编译器总是为类合成一个析构函数,它按成员声明的逆序自动撤销成员。
调用时机:
对象销毁时自动调用析构函数。
编写需求:
如果类在构造函数中动态分配了资源,则需要定义自己的析构函数来释放这些资源。按经验法则,如果类需要析构函数,那么通常也需要定义赋值操作符和复制构造函数,这称为“三法则”。
合成析构函数:
编译器合成的析构函数会按成员声明的逆序自动调用每个成员的析构函数(对于类类型成员),但对内置类型或复合类型成员(如数组)的撤销通常不执行额外操作。
析构函数用于执行任何在对象使用完毕后需要进行的清理工作,包括释放资源。即使定义了自己的析构函数,编译器合成的析构函数(针对成员)仍然会执行。对于不分配资源的类,可以定义一个空析构函数。
如果一个类不分配除内置类型和std::string外的资源,则不需要定义自己的析构函数,因为编译器合成的析构函数已经足够处理std::string成员的析构,从而释放内存。对于内置类型成员,析构时不需要特别处理。
13.4. 管理指针成员
当在C++类中使用指针成员时,需要特别注意其复制控制,浅拷贝问题。
/*一个带指针成员的简单类*/
class HasPtr {
public: // 构造函数,接受一个int指针和一个int值,并将它们分别赋值给ptr和val成员 HasPtr(int *p, int i) : ptr(p), val(i) { } // 获取ptr成员的指针值(注意:这不会复制指向的对象,只是返回指针的副本) int *get_ptr() const { return ptr; } // 获取val成员的值 int get_int() const { return val; } // 设置ptr成员为新的指针值 void set_ptr(int *p) { ptr = p; } // 设置val成员为新的整数值 void set_int(int i) { val = i; } // 获取ptr成员指向的整数值(假设ptr是有效的) int get_ptr_val() const { return *ptr; } // 设置ptr成员指向的整数值(注意:这里使用const是错误的,因为它修改了指针指向的值) // 理论上,这个函数不应该被声明为const,因为它修改了通过ptr指向的数据 // 但为了符合您的原始代码,我保持了这个错误,并在备注中指出 void set_ptr_val(int val) const { *ptr = val; } // 注意:这个函数违反了const的正确使用 private: // 一个指向int的指针成员 int *ptr; // 一个int类型的值成员 int val;
};
类设计者通常有三种管理指针成员的策略:
常规指针行为:
类不定义特殊的复制控制,因此指针成员表现出常规指针的所有行为。这包括指针可能指向同一对象,导致数据共享和潜在的数据不一致问题。同时,如果指向的对象被删除,指针将变成悬垂指针。
int obj = 0;
HasPtr ptr1(&obj, 42); //调用构造函数参数列表创建
HasPtr ptr2(ptr1); //用合成的复制构造函数创建//之后 ptr1和ptr2的int独立,但指针指向同一地址
智能指针行为:
类通过实现类似智能指针的行为来管理指针成员。这可以确保多个对象共享同一对象时,对象的生命周期得到适当管理,防止悬垂指针的出现。智能指针通常通过计数(如shared_ptr)或所有权转移(如unique_ptr)来实现。
说白了就是利用友元类搞了个中间类,把中间类当作计数器,有一个对象指向浅拷贝的指针地址,计数器就加一,少一个就减少计数器,计数器为0就调用中间类的析构释放指针,避免悬挂。
//仅供 HasPtr使用的私有类。该类没有声明Public成员
class U_Ptr { friend class HasPtr; //声明 HasPtr是友元,使其可以访问该类int *ip; //指针size_t use; //计数器U_Ptr(int *p): ip(p), use(1) { } //构造函数参数列表~U_Ptr() { delete ip; } //析构函数};
// 用户代码必须动态分配一个对象来初始化HasPtr实例,
// 并且不应该手动删除这个对象;HasPtr类会在适当时机自动删除它
class HasPtr {
public: // 构造函数,初始化时接受一个动态分配的int指针和一个整数值 // 内部使用U_Ptr(假设是一个用户自定义的用于计数的指针类)来管理这个指针的所有权 HasPtr(int *p, int i) : ptr(new U_Ptr(p)), val(i) {} // 拷贝构造函数,复制成员并增加U_Ptr的使用计数 // 注意:这里假设U_Ptr类内部有use计数成员,用于追踪有多少个HasPtr实例共享同一个U_Ptr HasPtr(const HasPtr &orig) : ptr(orig.ptr), val(orig.val) { ++ptr->use; } // 赋值操作符,需要实现深拷贝和适当的use计数调整 // 注意:此函数仅声明,未实现。实现时应确保正确处理自赋值和use计数的增减 HasPtr& operator=(const HasPtr&); // 析构函数,如果U_Ptr的使用计数减至0,则删除U_Ptr对象 // 这意味着当最后一个HasPtr实例被销毁时,它指向的动态分配内存也会被释放 ~HasPtr() { if (--ptr->use == 0) delete ptr; } private: U_Ptr *ptr; // 指向一个使用计数管理的U_Ptr类对象,U_Ptr负责实际指针的管理 int val; // 存储一个整数值,与ptr管理的动态分配内存无直接关联 // 注意:U_Ptr类未在代码中定义,假设它已定义并具有use成员来跟踪使用计数 // 并且具有适当的拷贝构造函数和析构函数来管理这个计数
};
值型行为:
类通过确保每个对象都拥有自己独立的对象副本,来模拟值类型的行为。这通常意味着在复制或赋值时,不仅复制指针地址,还复制指针指向的对象本身。这可以通过深拷贝实现,但需要注意性能和资源管理问题。
说白了就是直接new一个新的出来。
/* * HasPtr 类尽管包含指针成员,但表现出值类似行为: * 每次复制 HasPtr 对象时,都会复制 ptr 指针所指向的底层 int 对象。 * 这意味着每个 HasPtr 实例都拥有自己独立的 int 副本。 */
class HasPtr {
public: // 构造函数接收一个 int 引用和一个整数 i, // 创建一个指向 int 副本的新指针,并存储整数 i。 HasPtr(const int &p, int i) : ptr(new int(p)), val(i) {} // 拷贝构造函数:创建一个新的 int 副本,并复制 val 的值。 HasPtr(const HasPtr &orig) : ptr(new int(*orig.ptr)), val(orig.val) {} // 赋值操作符:需要实现以支持深拷贝。 // 注意:此函数仅声明,未实现。 HasPtr& operator=(const HasPtr&); // 析构函数:释放 ptr 指向的内存。 ~HasPtr() { delete ptr; } // 访问器:获取 ptr 指向的整数值。 int get_ptr_val() const { return *ptr; } // 访问器:获取 val 的值。 int get_int() const { return val; } // 修改器:设置 ptr 指向新的 int 指针(注意:这可能导致内存泄漏,除非非常小心)。 void set_ptr(int *p) { ptr = p; } // 修改器:设置 val 的值。 void set_int(int i) { val = i; } // 返回 ptr 指针(const 版本),允许 const 对象调用以获取指针但不修改指向的值。 int *get_ptr() const { return ptr; } // 尝试修改 ptr 指向的值,但声明为 const 是错误的,因为这会修改对象状态。 // 应从 const 成员函数中移除或重新设计。 // void set_ptr_val(int p) const { *ptr = p; } // 错误:const 成员函数不应修改成员变量 private: int *ptr; // 指向一个 int 的指针,每个 HasPtr 实例拥有自己独立的 int 副本。 int val; // 存储一个整数。
};//重载赋值操作符
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{ *ptr = *rhs.ptr; // 复制指针的值val = rhs.val; // 赋值int值return *this;
}
第十四章 重载操作符与转换
操作符重载允许程序员为类类型的对象重新定义标准操作符(如+、-、*、/、<<、>>等)的行为,使这些类类型的对象能像内置类型一样参与表达式运算,增强代码的可读性和易用性。
主要要点包括:
内置类型与类类型的操作符兼容性:
C++为内置类型提供了丰富的操作符支持,通过操作符重载,程序员可以将这些操作符扩展到自定义的类类型上,实现类似的操作。
操作符重载的用途:
通过操作符重载,可以定义类对象之间如何执行加法、减法、输入输出等操作,使类类型的对象使用起来更加直观和方便。
标准库中的操作符重载示例:
标准库中的容器类(如vector、list等)和迭代器通过定义下标操作符 [ ]、解引用操作符 * 和箭头操作符 ->,使得容器和迭代器的使用方式与内置数组和指针类似,极大地提高了代码的简洁性和可读性。
优势:使用操作符重载可以使代码更加简洁、易于理解和维护。例如,使用cout << v1 + v2;代替冗长的命名函数调用(如cout.print(v1.add(v2)).print("\n");),不仅代码更短,而且更符合C++的语法习惯和直觉。
注意事项:虽然操作符重载提供了很大的灵活性,但也需要谨慎使用。过度或不当使用操作符重载可能会导致代码难以理解和维护。特别是在设计库或框架时,应该遵循一定的命名和约定,避免混淆和误解。
14.1. 重载操作符
14.1.1. 重载操作符的定义
操作符重载概述
操作符重载允许程序员为类类型的对象定义标准操作符的行为。这是通过创建具有特殊名称(operator后跟操作符符号)的函数来实现的。这些函数可以有返回类型和参数列表,就像普通函数一样。
操作符重载的规则
操作数类型:重载操作符至少需要一个类类型或枚举类型的操作数。
优先级和结合性:操作符的优先级、结合性和操作数数目不能改变。
短路求值:重载的逻辑AND(&&)、逻辑OR(||)和逗号操作符(,)不保证短路求值特性。
默认参数:除了operator()外,重载操作符时不能使用默认参数。
类成员与非成员函数中的操作符重载
成员函数:
如果操作符作为成员函数重载,则它隐式地接收第一个操作数(通过this指针)。因此,对于一元操作符,没有显式参数;对于二元操作符,有一个显式参数。
非成员函数:
如果操作符作为非成员函数重载,则需要为每个操作数声明一个参数。
//+= 操作符作为 Sales_item类的成员函数重载,
//因为有隐含的this指针作为第一个参数,因此只需要传入一个参数
Sales_item& Sales_item::operator+=(const Sales_item&); //+ 操作符 作为 Sales_item类的非成员函数重载,没有this指针,因此需传入两个参数
Sales_item operator+(const Sales_item&, const Sales_item&);
友元函数与操作符重载
当操作符重载为非成员函数时,通常需要将其声明为所操作类的友元,以便它能够访问类的私有和保护成员。
class Sales_item { //操作符重载,非成员操作符声明为友元,以便访问私有数据friend std::istream& operator>> (std::istream&, Sales_item&); friend std::ostream& operator<< (std::ostream&, const Sales_item&); public: //成员操作符Sales_item& operator+=(const Sales_item&); }; //非成员操作符
Sales_item operator+(const Sales_item&, const Sales_item&);
使用重载操作符
重载操作符的使用方式与内置类型上的操作符相同。可以通过表达式语法隐式调用,也可以像调用普通函数那样显式调用。对于成员操作符,可以通过点或箭头操作符在对象上调用。
可重载与不可重载的操作符
可重载操作符:
包括大多数算术操作符(如+、-)、关系操作符(如<、>)、赋值操作符(如=、+=)、位操作符(如&、|)、输入输出操作符(<<、>>)等。
不可重载操作符:
包括作用域解析操作符(::)、成员访问操作符(.、.*)、条件操作符(?:)等,这些操作符的语义在C++中是固定的,不能改变。
通过连接其他合法符号可以创建新的操作符。
例如,定义一个 operator**以提供求幂运算是合法的。
/*重载操作符示例*/
class Sales_item {
public: Sales_item& operator+=(const Sales_item& rhs); // 非成员加法操作符,需要访问私有成员,因此声明为友元 friend Sales_item operator+(const Sales_item& lhs, const Sales_item& rhs); // ... 其他成员 ...
}; // 实现加法操作符
Sales_item operator+(const Sales_item& lhs, const Sales_item& rhs) { Sales_item temp = lhs; temp += rhs; // 调用成员操作符 return temp;
} // 使用
Sales_item item1, item2;
// 隐式调用
cout << item1 + item2 << endl;
// 显式调用
cout << operator+(item1, item2) << endl;
14.1.2. 重载操作符的设计
考虑操作符重载需遵循以下原则:
避免重载内置含义强的操作符:
如取地址(&)、逗号(,)、逻辑与(&&)、逻辑或(||),这些操作符有明确的内置行为,重载可能破坏其原有含义。
合理重载操作符:
根据类的实际需求决定哪些操作符需要重载,如相等测试(==)、输入输出(>>和<<)、复合赋值(如+=)等。
保持操作符行为一致性:
重载操作符应与其内置含义相似,特别是复合赋值操作符应与相应的基本操作符加赋值的行为一致。
提供必要的关系操作符:
若类用作容器键,应定义<操作符;
若支持排序或查找,还应定义==和其他关系操作符。
审慎定义不等操作符:若定义了==,通常也应定义!=以保持一致性。
选择成员或非成员实现:
赋值(=)、下标([ ])、调用(( ))、成员访问箭头(->)必须是成员。
改变对象状态的操作,如自增(++)、自减(--)、解引用(*),通常定义为成员。
对称的操作符(算术、关系、位操作符)最好定义为非成员函数。
避免滥用:只有当操作符与类的操作有直观的逻辑对应关系时,才应重载。否则,使用命名函数更清晰。
14.2. 输入和输出操作符
在C++中,为了支持自定义类型的I/O操作(如输入输出),通常需要对<<(输出操作符)和>>(输入操作符)进行重载。这些操作符的重载有特定的要求和实现方式,以确保它们的行为与标准库中的iostream类(如ostream和istream)一致。
14.2.1. 输出操作符 << 的重载
注意,IO操作符必须为非成员函数。
左操作数的要求:
在C++中,操作符重载允许我们改变操作符的行为,但对于某些操作符(如<<和>>),它们的左操作数类型在重载时必须是固定的。对于输出操作符<<,左操作数通常是ostream对象(如std::cout)。
如果我们将<<定义为类的成员函数,那么左操作数(即调用该函数的对象)将由于隐藏的 this指针被作为第一个参数,而自动成为该类的实例,这与我们期望的ostream对象不符。
//我们期望的
std::ostream对象 << 要输出的数据;//IO操作符定义成了 成员操作符以后会导致的
Sales_item对象 << std::ostream对象;
访问权限:
可以将 <<和>> 定义为类的友元函数来访问类的私有或受保护成员。
/*<<运算符重载举例*/
#include <iostream> // 自定义的 Point 类型
class Point {
public: int x, y; // 构造函数 Point(int xVal = 0, int yVal = 0) : x(xVal), y(yVal) {} // 为了允许访问私有成员以进行输出,可以将下面的函数声明为友元 // 但在这个简单的例子中,我们保持成员为公有的
}; // 重载 << 运算符以支持 Point 类型的输出 // 注意:这不是 Point 类的成员函数
std::ostream& operator<<(std::ostream& os, const Point& p) { // 将 Point 对象的状态输出到输出流 os os << "(" << p.x << ", " << p.y << ")"; // 返回输出流的引用,以支持链式调用 return os;
} int main() { Point pt(5, 3); // 使用重载的 << 运算符输出 Point 对象 std::cout << "The point is: " << pt << std::endl; return 0;
}
14.2.2. 输入操作符 >> 的重载
输入操作符设计要点
函数签名:
输入操作符通常是一个非成员函数,接受一个输入流(如std::istream)的引用作为第一个参数,和一个要读取数据的对象的非const引用作为第二个参数。返回的是对输入流的引用,支持链式调用。
读取数据:
函数体内部,从输入流中读取数据到对象的成员变量中。
错误处理:
必须检查每次读取操作是否成功,并处理可能的错误,如文件结束(EOF)、格式错误等。
恢复对象状态:
如果读取失败,通常是通过将其重置为默认状态。
设置条件状态:
在极端情况下,如果输入数据虽然技术上成功读取但不符合业务逻辑(如格式错误),可能需要手动设置输入流的 failbit来指示错误。并记得clear清除流的错误状态。
#include <iostream>
#include <limits> // 用于std::numeric_limits // 自定义的Point类
class Point {
public: int x, y; // 构造函数 Point() : x(0), y(0) {} // 默认构造函数 Point(int xVal, int yVal) : x(xVal), y(yVal) {} // 带参数的构造函数 // 为了简化,这里不添加其他成员函数或操作符重载
}; // 重载>>操作符以支持从istream读取Point对象
std::istream& operator>>(std::istream& in, Point& p) { // 尝试从输入流中读取两个整数 if (in >> p.x >> p.y) { // 如果读取成功,则不需要做额外操作 // 返回输入流的引用以支持链式调用 return in; } else { // 如果读取失败,则可能需要设置错误状态或恢复Point对象为默认状态 // 这里我们简单地将Point对象重置为(0, 0) // 注意:在实际应用中,可能需要根据具体情况来决定是否重置对象 p = Point(); // 重置Point对象为默认状态 // 重要的是,即使读取失败,我们也应该返回输入流的引用 // 这样调用者可以检查输入流的状态(如使用in.fail()) return in; // 返回输入流引用,但流可能已处于错误状态 }
} int main() { Point pt; // 尝试从std::cin读取Point对象 std::cout << "Enter x and y coordinates for a point (separated by space): "; std::cin >> pt; // 检查输入是否成功 if (std::cin) { std::cout << "You entered the point (" << pt.x << ", " << pt.y << ")" << std::endl; } else { std::cout << "Invalid input. Resetting point to (0, 0)." << std::endl; // 注意:由于我们在operator>>中已经重置了pt,这里不需要再次重置 // 但我们可以选择清除输入流的错误状态并忽略后续的错误输入 // std::cin.clear(); // 清除错误状态 // std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 忽略直到下一个换行符的所有字符 } return 0;
}
14.3. 算术操作符和关系操作符
算术操作符(如+、-、*、/等)在C++中通常被定义为非成员函数,它们接收两个操作数作为参数,并返回这两个操作数运算后的新结果。这些操作符不会修改其操作数的状态,因此它们的参数通常是对const对象的引用,以确保不会意外地修改这些对象。
对于Sales_item类这样的自定义类型,定义加法操作符operator+时,通常遵循以下原则:
不修改操作数:
通过将对操作数的引用声明为const,确保加法操作不会改变任何输入对象的状态。
返回新对象:
加法操作符返回一个新的Sales_item对象,该对象代表了两个操作数相加的结果。由于结果是一个全新的值,因此返回的是该值的副本,而不是引用。
利用复合赋值操作符:
为了提高效率和代码复用性,加法操作符通常会利用已经实现的复合赋值操作符(如+=)来执行实际的加法运算。这样做可以避免在加法操作中重复编写加法的逻辑,并且可以减少不必要的临时对象创建和销毁。
14.3.1. 相等操作符
在C++中,为类定义相等(==)和不等(!=)操作符时,设计原则可简述为以下几点:
数据等价性:确保 == 操作符能全面比较对象的关键数据,以判断两对象是否等价。
直观性:遵循用户习惯,使用 ==来 比较对象是否相等,提升代码可读性。
成对性:定义 == 时通常也定义 !=,通过逻辑非实现,满足用户期望和简化实现。
标准库兼容:使类能与标准库算法无缝协作,如 find 等,通过提供 == 操作符实现。
封装与访问:将操作符声明为友元,以维护封装同时访问私有成员。
/*重载 相等操作符 ==*/
#include <iostream> class Point {
public: int x, y; // 构造函数 Point(int x = 0, int y = 0) : x(x), y(y) {} // 重载相等操作符 bool operator==(const Point& other) const { return x == other.x && y == other.y; } // (可选)重载不等操作符,通过调用==实现 bool operator!=(const Point& other) const { return !(*this == other); }
}; int main() { Point p1(1, 2); Point p2(1, 2); Point p3(3, 4); std::cout << std::boolalpha; // 设置bool值的输出为true/false而不是1/0 std::cout << "p1 == p2: " << (p1 == p2) << std::endl; // 输出:p1 == p2: true std::cout << "p1 == p3: " << (p1 == p3) << std::endl; // 输出:p1 == p3: false return 0;
}
14.3.2. 关系操作符
在C++中,为类定义关系操作符(如<)时,需要特别注意它们与相等操作符(==)之间的逻辑一致性。
逻辑一致性要求 < 与 == 的定义需协调:< 不应导致 == 逻辑上的矛盾。
关联容器默认按 < 排序。
建议不为自定义类定义 < 以避免逻辑冲突。存储时,使用如 compare_isbn 的命名函数指定排序,保持 == 的纯粹性。
关系与相等操作符宜为非成员函数,增强灵活性,但访问私有成员时需声明为友元。
14.4. 赋值操作符
类赋值操作符 =可以重载以接受不同类型的右操作数,通常是成员函数并返回对左操作数(*this)的引用。如果没有自定义,编译器会合成默认的赋值操作符。
string类展示了这一特性,它定义了多个赋值操作符以支持不同类型(如const string&、const char*、char)的赋值操作。赋值操作符返回引用是为了支持连续赋值,并且避免不必要的临时对象创建与销毁。复合赋值操作符(如+=)也遵循此模式,返回左操作数的引用以便链式调用。
// 赋值操作符重载 Point& operator=(const Point& other) { if (this != &other) { // 自赋值检查 x = other.x; y = other.y; } return *this; // 返回当前对象的引用 }
14.5. 下标操作符
定义下标操作符operator[ ]:在自定义容器类中,operator[ ]用于通过索引访问元素。
非const版本:
对非 const 对象使用下标操作符,返回非常量引用,支持读写操作。
const版本:
对 const 对象使用下标操作符,返回常量引用,仅支持读取操作,防止修改。
#include <iostream>
#include <vector> class IntArray {
public: // 构造函数,初始化内部vector的大小 IntArray(size_t size = 0) : data(size, 0) {} // 下标操作符重载,用于非const对象 int& operator[](size_t index) { // 这里可以添加范围检查,但为简单起见,我们省略了 return data[index]; } // 下标操作符重载,用于const对象 const int& operator[](size_t index) const { // 同样,这里省略了范围检查 return data[index]; } private: std::vector<int> data; // 用于存储整数的vector
}; int main() { IntArray arr(5); // 创建一个包含5个元素的IntArray,初始值都为0 // 使用下标操作符访问和修改元素 arr[0] = 10; arr[1] = 20; // 访问const对象时,只能读取不能修改 const IntArray constArr(3); std::cout << "constArr[0] = " << constArr[0] << std::endl; // 输出:constArr[0] = 0 // constArr[0] = 5; // 这行代码会编译错误,因为constArr是const的 return 0;
}
14.6. 成员访问操作符
为了支持指针类型,例如迭代器,C++ 语言允许重载解引用操作符(*)和箭头操作符(->))。
箭头操作符必须定义为类成员函数。
解引用操作不要求定义为成员,但将它作为成员一般也是正确的。
模仿智能指针
模仿智能指针解决指针成员浅拷贝导致的类对象回收,成员指针不回收的问题。
ScreenPtr 类定义
假设我们有一个Screen类,并希望创建一个智能指针ScreenPtr来管理Screen对象的动态分配和销毁。
ScrPtr 辅助类
/*辅助类,封装了计数器和指针*/
class ScrPtr { friend class ScreenPtr; Screen *sp; size_t use; public: ScrPtr(Screen *p) : sp(p), use(1) {} ~ScrPtr() { delete sp; }
};
ScreenPtr 类
/*智能指针类,用来管理辅助类,指针参数传来的时候new新的辅助类,辅助类的计数器为0时回收指针*/
class ScreenPtr {
private: ScrPtr *ptr; public: // 必须通过指向Screen的指针来初始化 ScreenPtr(Screen *p) : ptr(new ScrPtr(p)) {} // 拷贝构造函数 ScreenPtr(const ScreenPtr &orig) : ptr(orig.ptr) { ++ptr->use; } // 赋值操作符 ScreenPtr& operator=(const ScreenPtr &rhs) { ++rhs.ptr->use; if (--ptr->use == 0) delete ptr; //辅助类计数器为0,删除辅助类对象ptr = rhs.ptr; return *this; } // 析构函数 ~ScreenPtr() { if (--ptr->use == 0) delete ptr; } // 重载解引用操作符 Screen& operator*() { return *ptr->sp; } const Screen& operator*() const { return *ptr->sp; } // 重载箭头操作符 Screen* operator->() { return ptr->sp; } const Screen* operator->() const { return ptr->sp; }
};
以下是 ScreenPtr 类的主要作用和特点:
封装与引用计数:
ScreenPtr 封装通过辅助类 ScrPtr 封装了了指向 Screen 的裸指针,并通过 ScrPtr 管理引用计数。每增加一个ScreenPtr 实例或复制,引用计数增加;实例销毁或重新赋值时,引用计数减少。
自动内存释放:
当引用计数归零,表示没有 ScreenPtr 指向该 Screen 对象时,自动释放 Screen 对象占用的内存,防止内存泄漏。
操作符重载:
重载了解引用(*)和箭头(->)操作符,使 ScreenPtr 像裸指针一样使用,但更安全
使用 ScreenPtr
Screen myScreen(10, 20); // 假设Screen有构造函数 //每次使用Screen对象初始化ScreenPtr对象
//都会创建辅助类ScrPtr
//ScrPtr对象回收时,只会删除辅助类,
//一直到计数器为0,才会删除辅助类对象,同时删除指针
ScreenPtr p(&myScreen);
p->display(std::cout); // 假设Screen有display成员函数
在这个例子中,ScreenPtr的 operator->返回 ptr->sp,即指向Screen对象的指针。由于箭头操作符的右操作数(这里是display)被编译器解释为对operator->返回对象的成员访问,因此实际上是在调用 ptr->sp->display(std::cout)。
14.7. 自增操作符和自减操作符
自增(++)和自减(--)操作符常被迭代器类实现,用于模拟指针行为来安全地遍历和访问序列元素。这类迭代器还能进行访问检查,防止越界。理想中,这类迭代器可通用于任意类型的数组,其实现依赖于后续将学习的类模板技术。
假设有这么一个类 CheckedPtr,该类指向一个数组,并为该数组中的元素提供访问检查。
那么这个CheckedPtr类是一个智能指针类,用于安全地访问和操作整数数组中的元素。它防止了数组越界访问,通过自增(++)和自减(--)操作符来移动指针位置,并检查是否越界。这个类同时支持前缀和后缀形式的自增和自减操作符,以提供与内置指针类型相似的行为。
核心要点:
构造函数:
接受两个int*类型的指针,分别指向数组的开始和结束(不包括结束指针指向的元素)。还有一个内部指针curr,指向当前访问的元素。
前缀自增/自减操作符:
前缀++:检查curr是否等于end,如果是则抛出out_of_range异常;否则,将curr加1(或减1,对于--),并返回对象的引用。
这些操作符通过修改curr指针来反映当前状态的改变,并返回对象的引用以支持链式调用。
后缀自增/自减操作符:
后缀++/--:这些操作符接受一个无用的int类型参数(编译器在调用时提供0),用于区分前缀和后缀形式。它们首先保存当前状态(即curr的位置),然后调用前缀操作符来改变状态,最后返回保存的状态(即改变前的值)。
这些操作符返回对象的副本,以反映操作前的状态,而不是修改后的状态。
安全性:
通过检查curr是否越界,CheckedPtr类提供了比裸指针更高的安全性。
使用场景:
适用于需要安全访问数组元素且不希望直接暴露裸指针给用户的场景。
#include <iostream>
#include <stdexcept> // 用于std::out_of_range template<typename T>
class CheckedPtr {
private: T* beg; T* end; T* curr; public: // 构造函数,需要数组的开始和结束指针 CheckedPtr(T* start, T* finish) : beg(start), end(finish), curr(start) {} // 前缀自增 CheckedPtr& operator++() { if (curr == end) { throw std::out_of_range("Attempt to increment past the end of the array"); } ++curr; return *this; } // 后缀自增 CheckedPtr operator++(int) { CheckedPtr temp = *this; // 保存当前状态 ++(*this); // 调用前缀自增 return temp; // 返回操作前的状态 } // 前缀自减(可选实现) CheckedPtr& operator--() { if (curr == beg) { throw std::out_of_range("Attempt to decrement below the beginning of the array"); } --curr; return *this; } // 后缀自减(可选实现,类似后缀自增) CheckedPtr operator--(int) { CheckedPtr temp = *this; --(*this); return temp; } // 解引用操作符,返回当前指向的元素 T& operator*() { return *curr; } // 箭头操作符(可选实现,如果需要的话) // T* operator->() { return curr; } // 其他成员函数,如检查是否到达末尾等,可以根据需要添加
}; int main() { int arr[] = {1, 2, 3, 4, 5}; CheckedPtr<int> ptr(arr, arr + 5); try { while (ptr != CheckedPtr<int>(arr + 5, arr + 5)) { // 注意:这里需要一种方式来比较CheckedPtr对象 std::cout << *ptr++ << " "; } } catch (const std::out_of_range& e) { std::cout << "Caught exception: " << e.what() << std::endl; } // 注意:上面的比较方式(ptr != CheckedPtr<int>(arr + 5, arr + 5))是伪代码, // 因为CheckedPtr类没有直接提供这样的比较操作符。在实际应用中,你可能需要 // 添加一个成员函数来检查是否到达末尾,或者重载比较操作符。 return 0;
} // 注意:上面的main函数中的比较逻辑是示意性的,并不直接适用于当前的CheckedPtr实现。
// 你需要为CheckedPtr类添加适当的成员函数或操作符来支持这种比较。
14.8. 调用操作符和函数对象
类可以重载函数调用操作符(operator()),使对象能够像函数那样被调用。
这种类通常被称为函数对象或仿函数。
#include <iostream> // 定义一个函数对象类
struct Add { // 重载调用操作符,接受两个整数参数并返回它们的和 int operator()(int x, int y) const { return x + y; }
}; int main() { // 创建Add类型的对象 Add adder; // 使用对象调用重载的调用操作符,就像调用函数一样 int sum = adder(5, 3); // 输出结果 std::cout << "The sum is: " << sum << std::endl; return 0;
}
14.8.1. 将函数对象用于标准库算法
一句话,函数对象可以代替函数,用作谓词,作为 STL函数类似于 find_if() 这种标准库算法的参数。
14.8.2. 标准库定义的函数对象
标准库定义了一组算术、关系与逻辑函数对象类。
标准库还定义了一组函数适配器,使我们能够特化或者扩展标准库所定义的以及自定义的函数对象类。这些标准库函数对象类型是在 <functional> 头文件中定义的。
标准库中的函数对象类提供了一种灵活的方式来表示和操作各种操作符,如加法、减法、比较等。这些类通过模板实现,允许用户指定操作数的类型。每个函数对象类都定义了一个调用操作符(operator()),该操作符实现了特定的操作。
操作符封装:
每个函数对象类封装了一个特定的操作符,
如 plus 封装了加法+,greater 封装了大于比较 >等。
模板类型:
函数对象类通常是模板类,允许用户指定操作数的类型。
例如,plus<int>用于整数加法,而plus<string>用于字符串连接。
一元与二元:
函数对象分为一元和二元两种。
一元函数对象(如 negate 和 logical_not)接受一个操作数,
二元函数对象(如 plus、minus、equal_to 等)接受两个操作数。
算法中的使用:
在标准库算法中,经常需要传递一个比较或操作函数来决定算法的行为。
函数对象提供了一种灵活的方式来覆盖算法的默认行为。
例如,sort 算法默认使用 operator< 进行升序排序,但可以通过传递 greater 函数对象来实现降序排序。
14.8.3. 函数对象的函数适配器
适配器在C++标准库中用于修改或增强函数对象的行为。
主要有两种类型的适配器:绑定器和求反器。
绑定器:
将二元函数对象转换为一元函数对象,通过将一个操作数绑定到某个固定值。
标准库提供了 bind1st 和 bind2nd 两个绑定器。
bind1st 将给定值绑定到二元函数对象的第一个参数位置,
bind2nd 则将给定值绑定到第二个参数位置。
例如,使用 bind2nd(less_equal<int>(), 10) 可以创建一个新的函数对象,该函数对象接受一个整数参数,并检查该参数是否小于或等于10。
求反器:
将函数对象的真值结果取反。
标准库提供了 not1 和 not2 两个求反器。
尽管 not2 在理论上存在,但实际上并不常用,因为大多数情况下我们处理的是谓词(即返回布尔值的函数对象),而谓词通常是一元的。not1用于将一元谓词的结果取反。
例如,not1(bind2nd(less_equal<int>(), 10))会创建一个新的函数对象,它检查一个整数是否大于10(即原操作的逆逻辑)。
在 count_if 的例子中,count_if(vec.begin(), vec.end(), bind2nd(less_equal<int>(), 10))会计算容器中所有小于或等于10的元素的数量。
而count_if(vec.begin(), vec.end(), not1(bind2nd(less_equal<int>(), 10)))则会计算容器中所有大于10的元素的数量,因为 not1 将 bind2nd 产生的函数对象的返回值取反,改变了逻辑条件。
14.9. 转换与类类型
在C++中,非 explicit 构造函数允许从其他类型隐式转换到类类型。同时,通过定义转换操作符,类类型可以隐式转换为其他类型,提供灵活性以适应不同上下文。这种转换虽便利,但可能增加代码复杂度。
14.9.1. 转换为什么有用
比如,在C++中,可以定义一个名为 SmallInt 的类来模拟一个安全的小整数类型,其值范围与 unsigned char 相同(0到255)。此类旨在通过捕获下溢和上溢来增强安全性。为了支持与内置 unsigned char 类似的操作,我们需要定义多种算术、关系及相等操作符。然而,直接为每种可能的组合实现这些操作符会导致大量的代码重复。
一种解决方案是为 SmallInt 类定义一个到 int 的隐式转换。这样做可以使得 SmallInt 对象能够在需要 int 类型参数的任何地方被接受,并自动转换为 int,从而利用C++内建的int类型操作符。尽管这种方法简化了代码,但它也引入了潜在的问题,如精度丢失(特别是与浮点类型交互时)和类型提升的不准确(例如,与long或unsigned long交互时)。
简而言之,通过为 SmallInt 定义一个到 int 的隐式转换,我们可以让该类对象在需要时自动转换为 int,从而利用现有的 int 操作符,但这种方法有其局限性,特别是在处理大型或浮点类型时。
14.9.2. 转换操作符
转换操作符是C++中的一种特殊成员函数,它允许将类类型的对象转换为其他类型(如内置类型、其他类类型等),但不包括数组、函数类型及 void类型。这些操作符在类定义体内声明,使用operator 目标类型作为函数名,且不能有返回类型声明和参数列表。转换函数应定义为const成员函数,因为通常不希望在转换过程中修改对象的状态。
在类类型参与表达式、条件判断、函数调用或需要显式类型转换的场合,编译器会自动调用这些转换操作符,将其转换为相应的类型。但是,转换过程只允许发生一次类类型转换,且该转换可与其他标准转换(如整数提升、类型转换等)组合使用,只要转换结果是所需的类型。
当需要类型转换时,如果存在多个可能的转换路径,编译器会根据一定的规则(如类型转换的“优劣”)来选择最合适的转换。但需要注意的是,复杂或过多的类型转换可能会导致代码难以理解和维护,因此在设计类时应谨慎使用转换操作符。
#include <iostream> class Point {
public: // 构造函数 Point(int x = 0, int y = 0) : x_(x), y_(y) {} // 转换操作符重载,将Point转换为int(这里以x坐标为例) operator int() const { return x_; } private: int x_; // 横坐标 int y_; // 纵坐标
}; int main() { Point p(5, 3); // 自动调用转换操作符,将Point对象p转换为int类型 int x = p; // 输出转换后的int值 std::cout << "The int value is: " << x << std::endl; // 另一个例子,演示在表达式中使用转换操作符 int sum = p + 10; // 这里p被隐式转换为int,然后与10相加 std::cout << "Sum with 10: " << sum << std::endl; return 0;
}
14.9.3. 实参匹配和转换
类类型转换的好处
简化使用:
为 SmallInt 类定义到 int 的转换,允许用户像使用基本类型一样使用 SmallInt 对象,无需为每种算术和关系操作重载操作符。一个转换操作符可替代多个重载操作符的定义,简化类实现。
类类型转换的潜在问题:
歧义性:当有多种转换方式可用时,编译器无法确定使用哪种转换,导致编译错误。
示例与解释
类定义(为示例说明潜在问题):
class SmallInt {
public: //int 和 doubel不具有二义性,但如果来个long,就和int有二义性了SmallInt(int = 0); // 从int转换 SmallInt(double); // 从double转换 //转换操作符重载函数operator int() const { return static_cast<int>(val); } operator double() const { return static_cast<double>(val); } //static_cast是C++的类型转换操作符,用来进行安全的数据类型转换private: std::size_t val;
};
int main() { // 隐式转换:从int到SmallInt SmallInt si1 = 42; // 调用SmallInt(int)构造函数 // 隐式转换:从double到SmallInt(但请注意,小数部分会被截断) SmallInt si2 = 3.14; // 调用SmallInt(double)构造函数 // 这里不会发生隐式转换,因为printValue的参数是int类型 // 但如果我们有一个从SmallInt到int的隐式转换操作符,那么下面的调用就是合法的 printValue(si1); // 调用operator int()将si1转换为int // 演示从SmallInt隐式转换到double(虽然通常不推荐) double d = si1; // 调用operator double()将si1转换为double std::cout << "Double value: " << d << std::endl; return 0;
}
类间转换:
比如,Integral 定义了转换成 SmallInt的隐式转换操作符,
SmallInt 定义了从 Integral转换成SmallInt 的构造函数.
void compute(SmallInt s).
现在函数以SmallInt为参数,传入 Integral发生隐式转换的时候,编译器不知道该调用 SmallInt的构造函数,还是该调用 Integral的隐式转换操作符,就会产生歧义性。
#include <iostream> class Integral {
public: Integral(int value) : val(value) {} // 隐式转换操作符,从Integral到SmallInt operator SmallInt() const { std::cout << "Converting Integral to SmallInt via operator\n"; return SmallInt(val); } int val;
}; class SmallInt {
public: SmallInt(int value) : val(value) {} // 隐式构造函数,从Integral到SmallInt SmallInt(const Integral& i) { std::cout << "Converting Integral to SmallInt via constructor\n"; val = i.val; } int val;
}; // 一个接受SmallInt参数的函数
void compute(SmallInt s) { std::cout << "Computing with SmallInt value: " << s.val << std::endl;
} int main() { Integral int_val(42); // 这里存在歧义:应该使用哪个转换? compute(int_val); // 编译器错误:ambiguous conversion //解决方案compute(static_cast<SmallInt>(int_val));return 0;
}
解决方案:
避免多重转换:尽量不定义多个到基本类型的转换。
显式转换:在存在歧义时,使用显式类型转换(如static_cast<T>())指定转换方式。
static_cast<SmallInt>(int_val); 显式指定调用参数的类型转换操作符,转成<T>
避免相互转换的类:最佳实践是避免设计两个相互提供隐式转换的类。
特殊情况
如果 SmallInt的构造函数改为接受const Integral&引用,看似有歧义的转换可能变得明确。
14.9.4. 重载确定和类的实参
在C++中,当涉及到重载函数和类类型转换时,理解编译器如何解析这些调用变得尤为重要。
重载函数和类类型转换
当有一个或多个重载函数时,编译器需要确定哪个函数是最合适的(即“最佳匹配”)来响应给定的函数调用。如果存在类类型转换,这个过程可能变得复杂,因为编译器需要评估哪些转换是可行的,以及哪个转换是最佳的。
/*示例1:单一转换操作符*/
class SmallInt {
public: operator int() const { return val; }
private: int val;
}; void compute(int);
void compute(double);
void compute(long double); SmallInt si;
compute(si); // 调用 compute(int)
在这个例子中,SmallInt 有一个到 int 的转换操作符,因此 compute(si) 调用可以解析为compute(int),因为这是一个完全匹配。
/*示例2:多个转换操作符导致的二义性*/
class SmallInt {
public: //转换操作符operator int() const { return val; } operator double() const { return static_cast<double>(val); }
private: int val;
}; void compute(int);
void compute(double); SmallInt si;
compute(si); // 错误:二义性 形参隐式类型转换
当 SmallInt 同时具有到 int 和 double 的转换操作符时,调用 compute(si) 变得二义性,因为编译器无法确定应该使用哪个转换。
显式转换消除二义性
compute(static_cast<int>(si)); // 明确调用 compute(int)
通过显式转换static_cast<T>( ),可以明确调用的转换操作符,从而消除二义性。
/*示例3:多个构造函数导致的二义性*/
class SmallInt {
public: SmallInt(int = 0);
}; class Integral {
public: Integral(int = 0);
}; void manip(const Integral&);
void manip(const SmallInt&); manip(10); // 错误:二义性
多个构造函数导致的二义性
一个函数重载,两种参数,而这两种参数都提供了接受 int 的构造函数时,调用 manip(10) 变得二义性,因为编译器无法确定应该使用哪个构造函数来构造对象。
/*多个构造函数导致的二义性*/
class SmallInt {
public: SmallInt(int = 0);
}; class Integral {
public: Integral(int = 0);
}; void manip(const Integral&);
void manip(const SmallInt&); manip(10); // 错误:二义性
显式构造函数调用消除二义性
manip(SmallInt(10)); // 明确调用 manip(const SmallInt&)
manip(Integral(10)); // 明确调用 manip(const Integral&)
14.9.5. 重载、转换和操作符
在C++中,重载操作符(如+、-等)实质上是重载了特定名称的函数。
候选函数集:
对于操作符+,候选函数集包括内置的+操作符(如果适用)、该操作符的普通非成员版本(如果定义),以及如果左操作数具有类类型且该类定义了该操作符的重载版本,则还包括该重载版本。
可行函数选择:
编译器会尝试为每个操作数找到可能的转换序列,以匹配候选函数集中的函数签名。
如果存在多个可行函数,编译器会进一步评估哪个函数是最佳匹配。
二义性和转换:
如果存在多个同样好的可行函数(例如,一个使用重载操作符,另一个使用内置操作符,且两者都需要类型转换),则调用将是二义性的。
定义多个转换操作符(如从类到多个算术类型的转换)可能会增加二义性的风险。
class SmallInt {
public: SmallInt(int = 0); // 从int到SmallInt的转换构造函数 operator int() const { return val; } // 从SmallInt到int的转换操作符 friend SmallInt operator+(const SmallInt&, const SmallInt&); // 重载的+操作符
private: std::size_t val;
}; // 正确的使用
SmallInt s1, s2;
SmallInt s3 = s1 + s2; // 使用重载的operator+ // 二义性的使用
int i = s3 + 0; // 错误:二义性
// 这里,编译器无法决定是应该将s3转换为int并使用内置的+操作符,
// 还是应该将0转换为SmallInt并使用重载的+操作符。 // 显式转换消除二义性
int j = static_cast<int>(s3) + 0; // 使用内置的+操作符
int k = s3 + static_cast<SmallInt>(0); // 使用重载的+操作符