第十五章. 面向对象编程
面向对象编程(OOP)在C++中主要通过三个核心概念实现:数据抽象、继承和动态绑定。这些概念让程序设计更加模块化、易于扩展和维护。
数据抽象:
通过类(Class)实现,类封装了数据(属性)和操作这些数据的方法(函数)。这样,类的外部用户不需要知道内部实现细节,只需通过公共接口与对象交互。
继承:
允许新类(派生类)继承现有类(基类)的属性和方法。这有助于代码复用和建立类之间的层次关系。
动态绑定(也称为晚期绑定或运行时多态):
允许在运行时根据对象的实际类型(而非声明类型)调用成员函数。
主要通过虚函数实现。
当一个类声明了虚函数,编译器会为这个类生成一个虚函数表。这个表包含了类中所有虚函数的地址。虚函数表作为类的元数据存储在程序的只读数据段。一个类的所有对象共享一个虚函数表。每个包含虚函数的类的对象都会包含一个指向其类虚函数表的指针。
/*封装、继承和动态绑定*/
class Book {
public:
virtual double getPrice() const {
return price;
}
// ... 其他成员 ...
};
class DiscountBook : public Book {
public:
double getPrice() const override { // 注意使用override关键字表明意图
return Book::getPrice() * (1 - discountRate);
}
// ... 其他成员 ...
};
// 使用时
Book* book = new DiscountBook("Programming 101", 30.0, 0.2);
std::cout << "Price: $" << book->getPrice() << std::endl; // 运行时决定调用哪个版本的getPrice
15.1. 面向对象编程:概述
面向对象编程(OOP)的核心思想之一是多态性(Polymorphism),它源自希腊语,意为“多种形态”。在C++中,多态性特别体现在通过继承相关联的类上,允许这些类(基类及其派生类)在多种形态下被互换使用。多态性主要通过虚函数实现,这些函数在基类中声明为 virtual,允许派生类提供特定于自身类型的实现。
继承是OOP中用于定义类之间关系的一种机制,它允许新类(派生类)继承现有类(基类)的属性和方法。派生类可以继承基类的成员,并可以重写(override)或添加新的成员函数,以适应派生类的特定需求。通过这种方式,继承层次结构被构建起来,其中有一个根类(基类),其他类直接或间接地继承自它。
多态性允许通过基类的引用或指针来调用派生类的成员函数,而具体调用哪个版本的函数(基类的还是派生类的)是在运行时根据对象的实际类型来决定的。这通过虚函数实现,虚函数在基类中声明,并在派生类中被重写以提供特定实现。
15.2. 定义基类和派生类
15.2.1. 定义基类
在C++中,基类不仅定义了接口(即成员函数),还定义了实现这些接口所需的数据成员。
基类的关键特性:
构造函数、成员函数、数据成员
访问控制和继承:
public:公有的。成员对类的用户和派生类都是可见的。
private:私有的。成员只能被类的成员函数和友元访问,派生类也不能访问。
protected:受保护的。成员可以被派生类的成员函数和友元访问,但不能被类的普通用户访问。
注意,派生类不会继承基类的 private成员,父类的 private占空间,子类不能使用它们,只能通过基类开放的函数接口访问。 之所以占空间,因为子类空间分配就是基类的内容+派生类额外的内容。
15.2.2. protected 成员
Protected访问标号的特性:
对类用户的限制:
像 private成员一样,protected 只能由派生类的成员函数或友元函数访问。
对派生类的访问:
protected 成员可以被派生类访问。这意味着派生类可以访问基类的 protected成员,无论是通过派生类对象自身还是通过另一个派生类对象的引用/指针。然而,这种访问仅限于派生类内部,不能通过基类类型的对象或指针/引用进行。
要注意,基类和派生类,都只能通过成员函数和友元,来对 受保护的成员进行访问,而且只能在类中进行访问,或者在友元类中以对象或引用的形式访问。
#include <iostream>
class Base {
protected:
int value = 42;
};
class Derived : public Base {
public:
void accessInDerived() {
// 通过派生类对象自身访问
std::cout << "Access in Derived through this: " << value << std::endl;
// 假设有一个同类型的派生类对象
Derived other;
// 通过另一个派生类对象的引用访问
std::cout << "Access in Derived through another Derived: " << other.value << std::endl;
}
// 尝试通过基类类型的指针访问(在派生类内部)
void accessThroughBasePtr() {
Base* basePtr = this; // 派生类对象可以隐式转换为基类指针
// 注意:这里虽然是在派生类内部,但实际上是通过基类指针访问的,但C++允许这样做
// 因为访问检查是基于对象的实际类型(这里是Derived)
std::cout << "Access in Derived through Base pointer (still allowed): " << basePtr->value << std::endl;
}
};
// 在这里,我们不能通过基类类型的指针或引用来访问protected成员
void externalAccess(Base* basePtr) {
// 下面的代码会导致编译错误,因为externalAccess不是Base或Derived的成员
// std::cout << basePtr->value << std::endl; // 编译错误
}
int main() {
Derived d;
d.accessInDerived(); // 正确:在派生类内部访问
d.accessThroughBasePtr(); // 正确:虽然在派生类内部通过基类指针访问,但基于对象的实际类型
// 尝试在外部通过基类指针访问(这将失败)
// externalAccess(&d); // 假设externalAccess尝试访问value,这将导致编译错误
return 0;
}
15.2.3. 派生类
派生类的定义
派生类是通过在类定义中指定基类来创建的。基类可以是单个或多个,通过类派生列表来指定。访问标签(如 public、protected、private)决定了基类成员在派生类中的访问权限。
// 基类定义
class Item_base {
public:
std::string book;
double price;
// 假设有isbn等其他成员和函数
// ...
virtual double net_price(std::size_t) const; // 虚函数
};
// 派生类定义
class Bulk_item : public Item_base {
public:
// 重写基类中的虚函数
double net_price(std::size_t cnt) const override; // 使用override关键字(C++11及以后)
private:
std::size_t min_qty;
double discount;
};
// 派生类成员函数实现
double Bulk_item::net_price(std::size_t cnt) const {
if (cnt >= min_qty)
return cnt * (1 - discount) * price;
else
return cnt * price;
}
派生类和虚函数
派生类通常会重写所继承的虚函数。如果派生类没有重写某个虚函数,则使用基类中定义的版本。虚函数在基类中声明为 virtual,在派生类中可以使用 override 关键字(C++11及以后)来明确指定重写。override关键字放在函数声明的函数名称后面。
派生类对象的组成
派生类对象由两部分组成:从基类继承的成员和派生类自己定义的成员。派生类中的函数可以像访问自己的成员一样访问基类的 public 和 protected 成员。
C++派生类的继承内容和自己新定义的内容在内存结构不保证连续。
已定义的基类
用作基类的类必须是已定义的。这是因为派生类需要知道基类成员的具体实现,以便能够使用它们。
派生类的层次结构
基类本身也可以是派生类,形成类的层次结构。最底层的派生类会继承其所有直接和间接基类的成员。
派生类的声明
如果只需要声明派生类(不进行定义),则不包括派生列表。前向声明是在C++(以及C语言)中用来声明一个类型或函数的存在,但不包含其定义的一种方式。
//正确的前向声明,声明类的存在,告诉编译器定义在后面给
class Item_base;
class Bulk_item;
15.2.4. virtual 与其他成员函数
在C++中,动态绑定允许在运行时根据对象的实际类型来确定调用哪个函数版本,这主要通过虚函数实现。以下是关键点的精简表述:
虚函数与动态绑定:
只有虚函数支持动态绑定。
虚函数通过基类类型的引用或指针调用时,在运行时确定调用基类或派生类覆盖的版本。
基类与派生类的关系:
每个派生类对象都包含基类部分。
基类类型的引用或指针可以指向基类对象或派生类对象。
静态类型与动态类型:
静态类型(编译时类型)是引用或指针声明的类型。
动态类型(运行时类型)是引用或指针实际指向的对象的类型。
虚函数的调用在运行时根据动态类型确定。
静态类的成员函数不可能是虚函数,因为它们在编译时确定空间,不支持动态绑定。
15.2.5. 公用、私有和受保护继承
在C++中,类可以继承自其他类,继承方式决定了基类成员在派生类中的访问级别。
基类成员可以是 public、protected或private。派生类通过指定继承类型(public、protected、private)来控制对基类成员的访问。
public继承:
保持基类成员的访问级别不变。基类public成员在派生类中也是public,protected成员在派生类中也是protected。
protected继承:
基类 public和 protected成员在派生类中变为protected。
private继承:
基类所有成员(public和protected)在派生类中变为private。
派生类不能增加基类成员的访问权限,但可以限制它们。
// 基类
class Base {
public:
void publicFunc() {} // 公有成员函数
protected:
void protectedFunc() {} // 受保护成员函数
private:
void privateFunc() {} // 私有成员函数
};
// 公有继承
class PublicDerived : public Base {
public:
void usePublicFunc() { publicFunc(); } // 可以,调用公有的成员函数
void useProtectedFunc() { protectedFunc(); } // 可以,调用受保护的成员函数(但在派生类中仍视为受保护)
// void usePrivateFunc() { privateFunc(); } // 错误,无法访问私有成员函数
};
// 受保护继承
class ProtectedDerived : protected Base {
void usePublicFunc() { publicFunc(); } // 可以,但在派生类中该函数受保护
void useProtectedFunc() { protectedFunc(); } // 可以,但在派生类中该函数受保护
// void usePrivateFunc() { privateFunc(); } // 错误,无法访问私有成员函数
};
// 私有继承
class PrivateDerived : private Base {
void usePublicFunc() { publicFunc(); } // 可以,但在派生类中该函数变为私有
void useProtectedFunc() { protectedFunc(); } // 可以,但在派生类中该函数变为私有
// void usePrivateFunc() { privateFunc(); } // 错误,无法访问私有成员函数
};
// 通过对象访问
Base b;
PublicDerived pd;
pd.publicFunc(); // 可以,publicFunc是PublicDerived的公有成员函数
// pd.protectedFunc(); // 错误,protectedFunc在PublicDerived中不可从外部访问
// pd.privateFunc(); // 错误,privateFunc在Base中就是私有的,无法访问
// 注意:由于PrivateDerived和ProtectedDerived的继承方式及成员函数的保护级别,
// 它们无法直接以这种方式使用(即无法直接调用其成员函数,除非通过其他公有成员函数间接调用)。
接口继承与实现继承
接口继承(public继承):
派生类继承基类的接口,可以像基类对象一样使用派生类对象。
实现继承(private/protected继承):
派生类在内部使用基类的实现,但不提供对外接口。
继承与访问控制
在C++中,继承可以控制基类成员在派生类中的访问级别。
如果进行 private或 protected继承,基类成员的访问级别在派生类中会比在基类中更受限。
默认继承保护级别
使用 class关键字定义的派生类默认具有 private继承。
使用 struct关键字定义的派生类默认具有 public继承。
这意味着,如果你没有明确指定继承方式(public, protected, private),编译器会根据你使用的是 class还是 struct来决定默认的继承方式。
/*继承与访问控制*/
class Base {
public:
std::size_t size() const { return n; }
protected:
std::size_t n;
};
class Derived : private Base {
// size() 和 n 在 Derived 中都是 private
};
// 恢复访问级别
class Derived : private Base {
public:
using Base::size; // 使 size() 在 Derived 中为 public
protected:
using Base::n; // 使 n 在 Derived 中为 protected
};
15.2.6. 友元关系与继承
友元关系不继承
友元关系不是继承的。
这意味着,如果类A是类B的友元,那么类A不会自动成为类B的任何派生类的友元。
友元关系的控制
每个类独立控制其成员的友元访问权限。如果一个类授予了另一个类或函数友元关系,这种关系只对该类及其成员有效,不会自动扩展到该类的派生类。
15.2.7. 继承与静态成员
当一个基类定义了静态成员,这个静态成员在整个继承层次中只有一个实例,无论派生出多少个派生类。
静态成员遵循类的访问控制规则,如果静态成员在基类中为私有(private),则派生类不能直接访问它。但是,如果静态成员是可访问的(比如是public或protected),则既可以通过基类访问,也可以通过派生类访问。
访问静态成员时,既可以使用作用域操作符(::),也可以使用点(.)或箭头(->)操作符(对于对象或指针)。
15.3. 转换与继承
在C++中,理解基类与派生类之间的转换对于掌握面向对象编程至关重要。派生类对象包含基类部分,因此可以自动将派生类对象的引用或指针转换为基类对象的引用或指针。但反方向的转换(从基类到派生类)并不自动发生,且通常不被允许,除非有明确的转换机制,如动态绑定。
派生类到基类的转换:
派生类对象的引用或指针可以隐式转换为基类对象的引用或指针。
这种转换是安全的,因为每个派生类对象都包含基类部分。
转换的可访问性取决于派生类继承基类的访问权限(public、protected、private)。
对象转换的复杂性:
对象本身(非引用或指针)没有从派生类到基类的直接转换。
可以用派生类对象初始化或赋值给基类对象,但这实际上是基类复制构造函数或赋值操作符的调用,会导致派生类特有的成员被“切掉”(即只复制或赋值基类部分)。
显式定义转换:
可以在基类中显式定义接受派生类对象作为参数的构造函数或赋值操作符,控制转换行为。
这不常见,因为通常基类希望独立于派生类存在。
可访问性:
派生类到基类的转换可访问性取决于继承方式(public、protected、private)。
public 继承允许用户代码和派生类使用转换。也就是只有public继承能在外界代码,将派生类对象的引用或指针当作基类类型的引用或指针来使用。
private 和 protected继承限制了对转换的访问。
/*派生类对象隐式转换为基类引用,派生类对象对基类对象进行初始化和赋值。*/
class Base {
public:
Base() {}
Base(const Base&) { // 复制构造函数
// 复制逻辑
}
Base& operator=(const Base&) { // 赋值操作符
// 赋值逻辑
return *this;
}
// ... 其他成员函数 ...
};
class Derived : public Base { // 使用public继承
public:
// Derived特有的成员
int derivedMember;
// ... 其他成员函数 ...
};
void useBaseRef(Base& b) {
// 使用基类引用
}
int main() {
Derived d;
Base& bRef = d; // 派生类对象引用转换为基类引用
useBaseRef(d); // 自动转换,调用useBaseRef
Base b = d; // 派生类对象对基类对象进行初始化,只复制基类部分
b = d; // 派生类对象对基类对象进行赋值,同样只处理基类部分
// 注意:这里没有定义从Derived到Base的显式转换函数
}
15.4. 构造函数和复制控制
派生类对象由它自己的成员(非static)和基类成员组成。这意味着当创建、复制、赋值或销毁派生类对象时,也会相应地构造、复制、赋值和销毁这些基类对象。派生类不会继承基类的构造函数、复制构造函数、赋值操作符或析构函数,而是需要自己定义这些成员(除非使用默认合成版本)。如果派生类没有显式定义这些成员,编译器会提供默认版本。
15.4.1. 基类构造函数和复制控制
基类的构造函数和复制控制成员(如构造函数、析构函数、复制构造函数和赋值操作符)本身不受继承机制影响,也就是不能被继承。
当基类被继承时,派生类在创建对象时需要调用基类的构造函数来初始化基类部分。
基类的构造函数可以像普通函数一样被设计为 public、protected或 private。大多数情况下,基类的构造函数是 public的,以便外界能够创建基类对象。但在某些情况下,基类可能想要提供一些只供派生类使用的构造函数,这时可以将这些构造函数声明为 protected。
15.4.2. 派生类构造函数
派生类构造函数在初始化自己的数据成员之外,还需要负责初始化基类部分。这通过在派生类构造函数的初始化列表中调用基类的构造函数来完成。如果派生类没有定义自己的默认构造函数,编译器会生成一个合成的默认构造函数,该构造函数会调用基类的默认构造函数来初始化基类部分,并默认初始化派生类的其他成员。
对于包含内置类型成员的派生类,通常建议定义自己的默认构造函数来确保所有成员都被正确初始化。
派生类构造函数通过初始化列表将参数传递给基类构造函数来间接初始化基类成员。一个类只能初始化其直接基类,不能直接初始化更上层的基类。这是因为每个类都定义了自己的接口,包括如何构造和初始化其对象。
/*派生类构造函数示例*/
class Item_base {
public:
Item_base(const std::string& book = "", double sales_price = 0.0)
: isbn(book), price(sales_price) {}
// 基类成员
std::string isbn;
double price;
};
class Disc_item : public Item_base {
public:
Disc_item(const std::string& book = "", double sales_price = 0.0,
std::size_t qty = 0, double disc_rate = 0.0)
: Item_base(book, sales_price), quantity(qty), discount(disc_rate) {}
//通过初始化成员列表,调用基类的构造函数,初始化基类成员
protected:
std::size_t quantity;
double discount;
};
class Bulk_item : public Disc_item {
public:
Bulk_item(const std::string& book = "", double sales_price = 0.0,
std::size_t qty = 0, double disc_rate = 0.0)
: Disc_item(book, sales_price, qty, disc_rate) {}
// 可能需要重写基类的某些成员函数,如 net_price
};
// 使用 Bulk_item 构造函数
Bulk_item bulk("0-201-82470-1", 50, 5, 0.19);
15.4.3. 复制控制和继承
派生类可以使用合成的复制控制成员(复制构造函数、赋值操作符、析构函数),这些操作会同时处理基类部分和派生类部分。然而,当派生类需要特殊行为时(如管理资源),它可能需要显式定义这些操作。
复制构造函数:
派生类复制构造函数需要显式调用基类的复制构造函数来初始化基类部分。
赋值操作符:
派生类赋值操作符需要显式调用基类的赋值操作符来更新基类部分,并处理派生类特有的成员赋值。
析构函数:
派生类析构函数不需要显式调用基类析构函数,因为编译器会自动处理。析构顺序是先派生类后基类。
/*派生类复制控制和继承示例*/
#include <iostream>
#include <string>
class Base {
public:
std::string name;
Base(const std::string& n) : name(n) {}
// 假设这里还定义了复制构造函数、赋值操作符和析构函数(如果需要)
// ...
};
class Derived : public Base {
int value;
public:
// 显式定义的复制构造函数
Derived(const Derived& other) : Base(other.name), value(other.value) {
std::cout << "Derived copy constructor called\n";
}
// 显式定义的赋值操作符
Derived& operator=(const Derived& rhs) {
if (this != &rhs) {
Base::operator=(rhs); // 调用基类的赋值操作符.来处理基类部分的赋值
value = rhs.value;
// 处理其他必要的清理工作(如果有)
}
return *this;
}
// 析构函数(如果基类有资源需要释放,这里不需要显式调用基类析构函数)
~Derived() {
std::cout << "Derived destructor called\n";
// 清理派生类特有的资源
}
// 其他成员函数和构造函数...
};
int main() {
Derived d1("Example");
d1.value = 10;
Derived d2 = d1; // 调用Derived的复制构造函数
Derived d3;
d3 = d1; // 调用Derived的赋值操作符
// 当d1, d2, d3离开作用域时,它们的析构函数将被调用
// 析构顺序:Derived, 然后是Base
}
注意事项
复制构造函数与赋值操作符:
当派生类显式定义这些时,需确保它们能正确复制或赋值基类部分及派生类特有的成员。这包括处理任何动态分配的资源,实现“深复制”。
析构函数:
派生类析构函数无需显式调用基类析构函数,因为编译器会自动按继承顺序(先派生类后基类)调用析构函数。
自赋值检查:
赋值操作符应检查左右操作数是否相同,以避免自赋值导致的潜在问题。
资源管理:
若基类管理资源(如内存、文件句柄),派生类析构时依赖编译器自动调用基类析构来正确释放资源。这确保了资源的正确清理,避免了悬挂指针和内存泄露等问题。
15.4.4. 虚析构函数
在继承体系中,如果基类指针可能指向派生类对象,并且这些对象最终需要通过基类指针被删除,那么基类的析构函数必须声明为虚函数。
这是为了确保当通过基类指针删除派生类对象时,基类的析构函数是虚函数,等于告诉编译器去找与指针实际指向的类型相匹配的析构函数版本。
构造函数和赋值操作符不能是虚函数。
构造函数在对象完全构造之前运行,此时对象的类型还不完整,因此无法确定调用哪个构造函数。
赋值操作符虽然可以声明为虚函数,但在实际使用中这样做没有意义且容易混淆,因为每个类都有自己的赋值操作符,其形参类型与类本身相同,这导致基类中的虚赋值操作符与派生类中的赋值操作符在参数类型上不一致。
/*虚析构函数示例*/
class Item_base {
public:
// 虚析构函数,即使它不执行任何操作
virtual ~Item_base() {}
};
class Bulk_item : public Item_base {
// ...
// Bulk_item 的析构函数(无论是显式定义还是合成)都会是虚的
};
// 使用示例
Item_base* itemP = new Bulk_item; // 基类指针指向派生类对象
delete itemP; // 调用 Bulk_item 的析构函数
15.4.5. 构造函数和析构函数中的虚函数
在派生类对象的构造过程中,首先调用基类构造函数来初始化基类部分,此时派生类部分尚未初始化,对象尚未完全成为派生类对象。相反,在析构过程中,首先撤销派生类部分,然后按逆序撤销基类部分。在这两个阶段,对象都是“部分构造”或“部分析构”的,且编译器将对象视为当前正在构造或析构的类型。
这种“不完整”状态对虚函数的绑定有重要影响。在构造函数或析构函数中调用虚函数时,将调用与当前构造或析构函数所属类型相匹配的版本,而不是最终对象的类型。这是为了防止在基类构造或析构时调用派生类的虚函数版本,因为此时派生类部分可能还未初始化或已被销毁,访问这些部分可能导致未定义行为或程序崩溃。
简而言之,构造和析构期间的对象类型被视为当前正在构造或析构的类型,这影响了虚函数的绑定,确保了调用的是与当前阶段相匹配的函数版本。
#include <iostream>
class Base {
public:
Base() {
// 在基类构造函数中调用虚函数
operation();
}
virtual ~Base() {
// 在基类析构函数中调用虚函数(虽然这通常不是个好主意)
// 但为了演示,我们还是这样做
operation();
}
virtual void operation() {
std::cout << "Base::operation()" << std::endl;
}
};
class Derived : public Base {
public:
int value = 0; // 派生类的一个成员变量
void operation() override {
// 尝试访问派生类的成员变量
std::cout << "Derived::operation(), value = " << value << std::endl;
}
};
int main() {
Derived* d = new Derived(); // 调用Derived的构造函数
delete d; // 调用Derived的析构函数
return 0;
}
上面的代码并不会如你所期望地那样运行。
在 Base的构造函数中调用虚函数operation()时,由于对象尚未完全构造为Derived类型(即派生类部分还未初始化),因此即使你通过Derived类型的指针创建了对象,调用的也将是Base类中的operation()版本。同样,在析构过程中,如果基类析构函数调用了虚函数,并且派生类已经部分或完全销毁,访问派生类成员也可能导致未定义行为。
15.5. 继承情况下的类作用域
类的继承机制使得派生类可以访问基类的成员。当在派生类对象上调用一个成员函数时,如果该成员在派生类中不存在,编译器会在基类的作用域中继续查找。
15.5.1. 名字查找在编译时发生
对象的静态类型(即对象、引用或指针在声明时指定的类型)决定了可以对其调用哪些成员函数。即使对象的动态类型(即对象在运行时实际的类型)可能是派生类类型,如果通过基类类型的指针或引用来访问该对象,那么只能访问基类中定义的成员函数。
15.5.2. 名字冲突与继承
当派生类定义了一个与基类同名的成员时,派生类的成员会“屏蔽”对基类成员的直接访问。这意味着在派生类的作用域中,同名成员将隐藏基类中的成员,使得只能通过基类类型来访问基类的成员。然而,可以使用作用域解析操作符(::)来明确指定访问基类中的被屏蔽成员。
/*名字冲突与继承示例*/
#include <iostream>
struct Base {
Base() : mem(0) {}
protected:
int mem; // 基类成员
};
struct Derived : Base {
Derived(int i) : mem(i) {} // 初始化Derived::mem
int get_mem() { return mem; } // 返回Derived::mem
protected:
int mem; // 屏蔽了Base中的mem
// 使用作用域操作符访问基类的mem
int get_base_mem() { return Base::mem; }
};
int main() {
Derived d(42);
std::cout << d.get_mem() << std::endl; // 输出42,访问的是Derived::mem
std::cout << d.get_base_mem() << std::endl; // 假设Base::mem被适当初始化或修改,这里将显示Base::mem的值
// 注意:在main函数中,我们无法直接访问Base::mem,因为它被Derived::mem屏蔽了
// 除非通过Derived类提供的某种机制(如get_base_mem())来访问
return 0;
}
15.5.3. 作用域与成员函数
当派生类和基类拥有同名的成员函数时,派生类的成员函数会屏蔽掉基类同名函数的所有重载版本。这意味着通过派生类类型的对象或指针调用该函数时,只会考虑派生类中定义的版本,除非显式使用作用域解析操作符来访问基类中的版本。
此外,成员函数也可以像其他函数一样被重载,但派生类在继承时,如果需要重定义某个重载版本,则必须重定义所有相关版本,或者一个也不重定义。为了避免这种限制,派生类可以使用 using声明来继承基类中成员函数,然后只重定义需要修改的版本。
/*使用using来声明继承基类的某个函数版本*/
#include <iostream>
struct Base {
void memfcn() { std::cout << "Base::memfcn()" << std::endl; }
void memfcn(int) { std::cout << "Base::memfcn(int)" << std::endl; }
};
struct Derived : Base {
// 派生类只重定义了一个版本,但屏蔽了基类的所有同名函数
void memfcn(int) { std::cout << "Derived::memfcn(int)" << std::endl; }
// 使用using声明来继承Base::memfcn()(无参版本)
using Base::memfcn;
};
int main() {
Derived d;
// 调用Derived中定义的版本
d.memfcn(10); // 输出: Derived::memfcn(int)
// 使用using声明继承的版本
d.memfcn(); // 输出: Base::memfcn(),因为Derived通过using继承了它
// 尝试调用Base中未被重定义的其他版本(如果有的话),这里假设Base还有其他重载版本
// ... (注意:在这个例子中,Base没有其他重载版本,但如果有,并且没有使用using声明,则无法通过Derived对象调用它们)
return 0;
}
15.5.4. 虚函数与作用域
为了获得动态绑定,必须通过基类的引用或指针来调用虚函数。
这意味着,编译器在编译时会在基类中查找函数名,并在运行时根据对象的实际类型来确定调用哪个版本的函数。
虚函数在基类和派生类中必须拥有相同的原型,这是因为如果它们不同,就无法通过基类类型的引用或指针来调用派生类中相应的函数。如果派生类定义了与基类虚函数同名的函数但参数列表不同,那么该函数会隐藏基类中的虚函数,而不是重定义它。
确定函数调用的四个步骤:
确定调用对象的静态类型:首先确定调用函数的对象、引用或指针的静态类型(即编译时类型)。
名字查找:在该类型的类及其基类中查找函数名,沿继承链向上直到找到或到达基类链的末尾。
类型检查:一旦找到函数名,进行类型检查以确保调用是合法的(即参数匹配等)。
生成代码:如果函数是虚函数且通过引用或指针调用,编译器生成代码以在运行时根据对象的实际类型来确定调用哪个版本的函数。否则,直接调用找到的函数。
15.6. 纯虚函数
如果有一个基类(如Disc_item)旨在作为其他类的基类,且某些成员函数(如net_price)在当前基类中不需要具体实现,我们可以将这些函数声明为纯虚函数。纯虚函数通过在函数声明的末尾添加= 0来标记,表示该函数在基类中不提供实现,且派生类必须提供该函数的实现。
通过将net_price声明为纯虚函数,Disc_item类成为了一个抽象基类。这意味着不能直接创建Disc_item类型的对象,因为纯虚函数的存在使得类的对象无法被完整实例化。然而,Disc_item可以作为其他类的基类,这些派生类需要实现net_price函数以提供具体的行为。
声明了纯虚函数的类会成为抽象基类,不能创建抽象基类的对象。
抽象基类只用来作为派生类的对象的组成部分。
/*纯虚函数示例*/
class Item_base {
public:
virtual double net_price(std::size_t n) const {
// 返回不打折的价格
return price() * n; // 假设price()是一个返回价格的函数
}
// 其他成员函数...
};
// Disc_item类声明net_price为纯虚函数
class Disc_item : public Item_base {
public:
// 纯虚函数,要求派生类提供实现
virtual double net_price(std::size_t) const = 0;
// 可能还有其他与折扣无关的成员函数或受保护/私有数据
};
// 尝试创建Disc_item对象会导致编译错误
// Disc_item discounted; // 错误:不能定义Disc_item对象,因为它是抽象的
// 正确的用法是创建一个从Disc_item派生的类,并实现net_price
class Bulk_item : public Disc_item {
public:
double net_price(std::size_t n) const override {
// 实现根据批量购买的折扣价格计算
return price() * n * (1 - discount()); // 假设discount()是一个返回折扣的函数
}
// 其他成员函数,包括可能的折扣计算逻辑...
};
// 现在可以创建Bulk_item对象,它可以正确地调用net_price函数
Bulk_item bulk;
15.7. 容器与继承
若尝试使用容器(如multiset)直接存储继承体系中的对象时,会遇到“对象切片”问题。
这意味着,当尝试将派生类对象存入以基类类型为模板参数的容器中时,只有对象的基类部分会被复制并保存,派生类特有的部分将被丢弃(即“切掉”)。这会导致存储的对象失去其派生类的特性,比如打折价格计算功能。
解决此问题的一种方法是,使用容器存储对象的指针(如 multiset<Item_base*> 或 multiset<unique_ptr<Item_base>>),这样可以避免对象切片。但这样做需要用户管理内存,确保对象在容器生命周期内有效,并在适当的时候释放内存。
15.8. 句柄类与继承
在C++面向对象编程中,直接使用对象(非指针、非引用)会限制多态性的使用,因为对象会被切片,丢失其派生类的特性。为了支持多态行为,通常需要使用指针或引用来调用基类对象,这样可以在运行时根据对象的实际类型调用相应的虚函数。
然而,直接使用指针或引用会增加用户的负担,特别是在与容器等结构交互时,需要管理内存和指针的生命周期。
为了解决这个问题,C++中常用的一种技术是定义句柄类或包装类,它内部存储和管理基类指针。这种句柄类提供了对继承层次中对象的访问,但隐藏了指针的细节,使用户可以更方便地以值的方式操作对象,同时保持多态性。
15.8.1. 指针型句柄
在C++中,为了支持多态行为并减轻用户管理内存的负担,使用句柄管理类的指针。
例如,我们定义了一个名为Sales_item的句柄类,它内部存储和管理基类Item_base及其派生类对象的指针和使用计数。Sales_item句柄类提供了类似指针的操作符重载(*和->),允许用户以值的方式操作对象,同时保持多态性
复制/赋值:共享对象时增加使用计数,不共享时减少,为0则释放。
析构:减少使用计数,为0时释放资源。
操作符:重载*和->,以指针方式访问对象。
辅助函数:管理使用计数,负责释放资源。
#include <memory> // 用于std::unique_ptr(可选,这里不使用,但可作为替代方案)
#include <cstddef> // 包含std::size_t
class Item_base {
public:
// 假设Item_base有一些成员和函数
virtual ~Item_base() {} // 虚析构函数,确保多态删除
// ... 其他成员和函数
};
class Sales_item {
public:
Sales_item() : p(nullptr), use(new std::size_t(1)) {}
// 构造函数,从Item_base对象动态创建副本
Sales_item(const Item_base& item) : p(item.clone()), use(new std::size_t(1)) {}
// 复制构造函数
Sales_item(const Sales_item& i) : p(i.p), use(i.use) { ++*use; }
// 赋值操作符
Sales_item& operator=(const Sales_item& rhs) {
if (this != &rhs) { // 自赋值检查
decr_use(); // 减少当前对象的使用计数
p = rhs.p;
use = rhs.use;
++*use; // 增加新对象的使用计数
}
return *this;
}
// 析构函数
~Sales_item() { decr_use(); }
// 解引用操作符
const Item_base& operator*() const { return *p; }
// 成员访问操作符
const Item_base* operator->() const { return p; }
private:
Item_base* p; // 指向管理的基类对象
std::size_t* use; // 使用计数
// 减少使用计数,并在计数为0时释放资源
void decr_use() {
if (--*use == 0) {
delete p; // 释放Item_base对象
delete use; // 释放使用计数
}
}
// 假设Item_base有一个clone函数来创建自己的副本
// 这通常是一个虚函数,由派生类实现
// static Item_base* clone() const = 0; // 虚函数声明(如果Item_base是抽象基类)
};
注意赋值时,A=B,A会被赋值成B,因此A句柄的使用次数应该减一,同时B句柄的使用次数加一,然后把A句柄的指针换成B句柄的指针。
15.8.2. 复制未知类型
在使用句柄管理类指针时,需要面对的一个问题是,在使用初始化句柄时我们需要面对的对象的实际类型是未知的,可能是基类,也可能是派生类。
通过定义基类的 clone方法,让派生类通过该方法返回自己的对象。因此句柄通过构造函数初始化时可以获得要管理的类对象的副本。
1. 定义基类及其 clone 方法
在基类 Item_base 中,定义一个虚函数 clone(),该函数返回一个指向基类类型的指针。这是多态的基础,允许派生类覆盖这个函数并返回派生类类型的指针。
默认构造函数、复制构造函数,如果没显示定义会自动合成。
2. 派生类覆盖 clone 方法
每个派生类都需要覆盖 clone() 方法,并返回该派生类类型的指针。
3. 使用 clone 方法在句柄类中创建对象副本
/*定义基类及其克隆方法*/
class Item_base {
public:
// 虚析构函数也很重要,用于安全删除对象
virtual ~Item_base() {}
// 虚 clone 函数
virtual Item_base* clone() const {
return new Item_base(*this); // 默认实现:复制基类对象
}
// 其他成员函数...
};
/*派生类覆盖克隆方法*/
class Bulk_item : public Item_base {
public:
// 覆盖 clone 函数,返回派生类类型的指针
Bulk_item* clone() const override {
return new Bulk_item(*this); // 复制派生类对象
}
// 派生类特有的成员函数和数据成员...
};
/*使用克隆方法在句柄类中创建对象副本*/
class Sales_item {
Item_base* p; // 指向 Item_base 或其派生类的指针
std::size_t* use; // 使用计数
public:
// 接受 Item_base 对象的构造函数
Sales_item(const Item_base& item) : p(item.clone()), use(new std::size_t(1)) {
// 构造函数体...
}
// 析构函数、拷贝构造函数、拷贝赋值运算符等其他必要的特殊成员函数...
// 其他成员函数...
};
15.8.3. 句柄的使用
/*文件描述符就是经典的句柄*/
#include <windows.h>
#include <iostream>
int main() {
// 打开文件,获取文件句柄
HANDLE hFile = CreateFile(
TEXT("example.txt"), // 文件名
GENERIC_READ | GENERIC_WRITE, // 访问模式
0, // 共享模式
NULL, // 安全属性
OPEN_EXISTING, // 创建/打开模式
FILE_ATTRIBUTE_NORMAL, // 文件属性
NULL); // 模板文件的句柄
if (hFile == INVALID_HANDLE_VALUE) {
// 如果hFile是INVALID_HANDLE_VALUE,表示打开文件失败
std::cerr << "Failed to open file." << std::endl;
return 1;
}
// 此时hFile就是文件的句柄,可以用于后续的文件操作
// ...(此处省略使用hFile进行文件操作的代码)
// 步骤2:关闭文件句柄
// 当文件操作完成后,需要关闭文件句柄以释放资源
CloseHandle(hFile);
return 0;
}
第十六章 模板和泛型编程
泛型编程通过模板实现,允许编写独立于具体类型的代码,而模板是创建可应用于多种数据类型的类或函数的蓝图,是C++中泛型编程的基础。
16.1. 模板定义
通过重载函数来比较不同类型值的方法存在重复性和局限性,对于未知类型的比较则不适用,需要一种更灵活的方式来避免这种重复并确保可扩展性。
16.1.1. 定义模板函数
我们可以使用函数模板来避免为每种类型重复编写几乎相同的比较函数。函数模板定义了一种独立于类型的函数框架,编译器可以根据具体的使用情况自动实例化出对应类型的函数版本。
模板定义示例:
/*函数模板定义示例*/
template <typename T>
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
这里,template <typename T> 定义了一个模板,其中 T 是一个类型参数,它将在模板实例化时被具体的类型所替换。
使用模板:
当使用模板函数时,编译器会自动根据提供的参数类型推断出模板参数 T 的具体类型,并生成相应类型的函数实例。
/*使用函数模板*/
int main() {
// 实例化 int 类型的 compare 函数
std::cout << compare(1, 0) << std::endl; // 输出 -1
// 实例化 std::string 类型的 compare 函数
std::string s1 = "hi", s2 = "world";
std::cout << compare(s1, s2) << std::endl; // 输出 -1,因为 "hi" < "world"
return 0;
}
内联函数模板:
与普通的内联函数类似,函数模板也可以被声明为内联的,以建议编译器在调用点内联展开函数体。
template <typename T> //模板形参
inline T min(const T &a, const T &b) { //模板函数声明的同时定义
return (a < b) ? a : b;
}
注意,inline 关键字应放在模板参数列表之后、返回类型之前。
16.1.2. 定义类模板
类模板允许我们定义一种可以操作多种数据类型的类。与函数模板类似,类模板也是以 template 关键字开头,后跟模板形参表(一个或多个模板形参的列表)。
template <class Type> |
在类模板中,模板形参可以用作类型占位符,在实例化类时由具体的类型所替换。
以 Queue 类模板为例,它接受一个名为 Type 的模板类型形参,用于定义队列中可以存储的元素类型。Queue 类模板提供了基本的队列操作接口,如 push、pop、front 和 empty。
类模板定义示例:
/*类模板示例*/
template <class Type>
class Queue {
public:
Queue(); // 默认构造函数
Type& front(); // 返回队头元素的引用
const Type& front() const; // 常量版本,用于不修改队列的情况
void push(const Type&); // 在队尾增加一项
void pop(); // 从队头删除一项
bool empty() const; // 检查队列是否为空
private:
// 类的实现细节,如存储队列元素的容器等
};
使用类模板:
与函数模板不同,使用类模板时,必须在模板名后显式指定模板具体的类型。编译器将使用这些实参来实例化类模板的特定类型版本。
Queue<int> qi; // 实例化一个存储整数的队列
Queue<std::vector<double>> qc; // 实例化一个存储双精度浮点数向量的队列
Queue<std::string> qs; // 实例化一个存储字符串的队列
16.1.3. 模板形参
模板形参的名字(如T、Glorp)在模板定义中仅作为占位符,没有本质含义,可以替换为任何合法的标识符。模板形参可以是类型形参(表示未知类型),也可以是非类型形参(表示未知值,但这种情况较少见且通常用于数值或指针)。
模板形参的作用域从声明开始,直到模板定义结束。如果模板形参的名字与全局作用域中的名字冲突,模板形参将屏蔽全局名字。
模板形参的名字在模板内部不能重新声明为不同的类型或对象,也不能在同一模板形参表中重复使用。但是,它们可以在不同的模板中重复使用。
模板的声明和定义可以分开,声明时必须明确指出这是一个模板。在同一模板的声明和定义中,模板形参的名字不必相同,但通常为了清晰和一致性,会保持它们一致。
模板类型形参前面必须使用 class或 typename关键字(C++11后两者可互换),而非类型形参前面则需要指定其类型。
// 模板函数声明
template <class T> int compare(const T&, const T&);
// 模板函数定义
template <class Type>
int compare(const Type& v1, const Type& v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
// 模板形参作用域示例
typedef double T; // 全局类型别名
template <class T> T calc(const T& a, const T& b) {
T tmp = a; // tmp的类型是模板形参T指定的类型,而非全局的double
return tmp;
}
// 模板形参名字不能重用示例(错误)
template <class T> T calc(const T& a, const T& b) {
typedef double T; // 错误:重新声明模板形参T
// ...
}
// 模板形参名字在不同模板中可以重用
template <class T> T calc(const T&, const T&);
template <class T> int compare(const T&, const T&); // 这里的T与上面的T无关
// 模板声明与定义中的形参名字可以不同(但通常保持一致)
template <class T> T calc(const T&, const T&); // 声明
template <class U> U calc(const U& a, const U& b) { /* ... */ } // 定义
16.1.4. 模板类型形参
在模板中,class 和 typename 作为类型形参的关键字时,在模板形参表中具有相同的含义,都用于表示后面跟随的是一个类型。这两个关键字在模板形参表中可以互换使用,但通常typename更直观地表示“类型名称”,尤其是在与类类型和非类类型(如内置类型)一起使用时。
在模板内部,当需要引用某个类型的成员类型(如类的size_type)时,如果编译器无法直接判断该成员是一个类型还是一个对象,则必须使用 typename关键字来明确告知编译器这是一个类型。这是因为在C++中,成员名默认被当作对象名处理,除非明确指定为类型。
// 使用 class 和 typename 在模板形参表中
template <typename T, class U> void func(const T& t, U u) {
// 在这里,T 和 U 都是类型
}
// 假设有一个类 Parm,它有一个类型成员 size_type
template <class Parm>
Parm fcn(Parm* array) {
// 告诉编译器 Parm::size_type 是一个类型,否则编译器把泛型的成员当对象处理
typename Parm::size_type size = someFunctionToGetSize(array);
// ... 使用 size 变量
}
// 如果没有使用 typename,编译器会报错,因为它默认 Parm::size_type 是一个对象或静态成员
// Parm::size_type size = someFunctionToGetSize(array); // 错误,除非 Parm::size_type 确实是一个对象
注意:在模板中引用依赖类型(即依赖于模板参数的类型)时,必须使用typename。依赖类型是指其定义或存在依赖于模板参数的类型。例如,在上面的fcn函数中,Parm::size_type是一个依赖类型,因为它依赖于模板参数Parm。
此外,即使在不确定某个成员是否是类型时,使用typename来指定它也不会有害。这可以作为一种防御性编程的策略,以确保代码的清晰和正确性。
关于typename用法的例子:
#include <iostream>
// 一个简单的依赖类
template<typename T>
class Dependency {
public:
using value_type = T; // 这是一个类型成员 ,using A = B用来起给类型起别名
static constexpr int static_value = 42; // 这是一个静态成员变量
};
// 另一个模板类,它使用Dependency作为模板参数
template<typename Parm>
class User {
public:
// 尝试使用Parm的value_type成员
void func() {
// 默认情况下,Parm::value_type被当作对象名,这会导致编译错误
// Parm::value_type var; // 错误:编译器不知道value_type是一个类型还是一个对象
// 使用typename明确指定Parm::value_type是一个类型
typename Parm::value_type var; // 正确:现在编译器知道value_type是一个类型
// 尝试使用Parm的static_value成员
// 因为static_value是一个静态成员变量,所以不需要typename
std::cout << "Static value: " << Parm::static_value << std::endl;
}
};
int main() {
User<Dependency<int>> user;
user.func(); // 输出:Static value: 42
// 注意:在这个例子中,var没有被初始化,但在实际使用中应该初始化它
// 只是为了展示如何正确引用类型成员
}
16.1.5. 非类型模板形参
非类型模板参数允许模板函数或模板类接受非类型(如整数、指针等)的参数,这些参数在编译时必须是常量表达式。这在处理如数组大小等需要常量表达式作为参数的场合特别有用。
函数模板的非类型形参
函数模板可以定义非类型形参,这些形参在模板定义内部作为常量值使用。
非类型形参的类型在模板形参表中指定,当调用模板函数时,这些形参由传递给模板的实参的值来替代。
常见的用途包括指定数组的长度,因为数组的长度在C++中必须是常量表达式。
/*非类型模板参数示例*/
template <class T, size_t N>
void array_init(T (&parm)[N]) { // 形参是引用
for (size_t i = 0; i != N; ++i) {
parm[i] = 0;
}
}
int main() {
int x[42];
double y[10];
array_init(x); // 实例化 array_init<int, 42>
array_init(y); // 实例化 array_init<double, 10>
const int sz = 40;
int y2[sz + 2];
array_init(y2); // 等价实例化,也是 array_init<int, 42>
return 0;
}
类型等价性与非类型形参
对于非类型模板参数,如果两个表达式的求值结果相同,则它们在模板实例化时被认为是等价的。
在上面的例子中,int y2[sz + 2]; 尽管sz是一个变量,但sz + 2的求值结果(42)在编译时是已知的,因此array_init(y2);会实例化与array_init(x);相同的模板版本(array_init<int, 42>)。
16.1.6. 编写泛型程序
在编写模板时,确保代码能够通用并兼容多种类型是非常重要的。模板的设计允许代码在不同的数据类型上工作,但这也要求程序员对模板实例化的类型做出合理的假设。
举例,compare 函数模板
模板函数 compare 旨在比较两个类型为 T 的对象,并返回一个整数表示它们的相对顺序。为了使其尽可能通用,我们假设类型 T 支持 < 操作符。此外,将参数声明为 const 引用可以避免不必要的复制,并允许对不可复制的类型进行操作。
template<typename T>
int compare(const T& v1, const T& v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
通过将参数声明为const引用,你告诉编译器你不需要这个对象的副本,而是想要直接操作原始对象本身。这样,编译器就不会生成副本,而是让函数直接引用传递给它的对象。
有些C++类型(特别是那些重定义了拷贝构造函数或赋值运算符的类型,或者那些没有默认拷贝构造函数和赋值运算符的类型)可能不支持复制。这些类型通常被称为“不可复制”的类型。通过将参数声明为const引用,你可以绕过这个限制,因为引用本质上不是对象的副本,而是对原始对象的直接引用。
16.2. 模板实例化
模板是一个蓝图,用于生成特定类型的类或函数。这个过程称为实例化。
类模板实例化:
当使用Queue<int>或Queue<string>时,编译器会生成具体类型的Queue类。
函数模板实例化:
当调用函数模板(如compare)时,编译器会根据传入的参数类型推断模板实参,并生成对应的函数实例。
16.2.1. 模板实参推断
编译器从函数调用或模板类实例化中的实际参数类型推断模板参数的类型。
/*函数模板实参推断*/
//定义一个模板函数
template<typename T>
int compare(const T& v1, const T& v2) {
// ...
}
//使用模板参数
int main() {
compare(1, 0); // 推断 T 为 int
compare(3.14, 2.7); // 推断 T 为 double
compare(si, 1024); // 错误:类型不匹配,si 为 short,1024 为 int
}
类型形参的实参的受限转换
const 转换:函数模板实参和形参,const对象和非const对象之间可以互转。但是const指针和非const指针不能互转。
数组或函数到指针的转换:如果模板形参不是引用类型,则数组和函数实参会隐式转换为指针。
注意,C++是不支持直接定义数组的引用的,数组的引用意味着每个数组元素的直接操作。
对于固定大小的数组,可以使用std::array;
对于可变大小的数组,可以使用std::vector。
这些容器类型支持引用,允许你传递对容器的引用给函数,从而在函数内部修改容器的内容,这些修改会反映到原始容器上。
template <typename T> T fobj(T, T); //模板函数,非 const 引用参数
template <typename T> T fref(const T&, const T&); //模板函数,const引用参数
string s1("a value"); //非Const
const string s2("another value"); //const
fobj(s1, s2); // 可以转换,const对象转非const对象
fref(s1, s2); // 可以转换,非const对象转const对象
int a[10], b[42];
fobj(a, b); //可以转换,数组转指针
//注意,C++不允许将非const指针隐式转换为const指针的引用
fref(a, b); //不可以转换,非const指针不能转const指针
模板与函数指针
可以用函数模板实例化来初始化或赋值给函数指针。
template<typename T>
int compare(const T& v1, const T& v2) {
// ...
}
int (*pf)(const int&, const int&) = compare; // 实例化 compare<int>
当模板实参不能从函数调用或模板类实例化中的参数唯一确定时,实例化会失败。
16.2.2. 函数模板的显式实参
当模板函数的模板参数无法直接从函数参数中推导出来,我们就需要显式地指定模板实参。
#include <iostream>
// 一个简单的模板函数,它接受两个模板参数T和U,但实际上只使用了T
template <typename T, typename U>
void printType() {
std::cout << "T is " << typeid(T).name() << ", U is " << typeid(U).name() << std::endl;
}
int main() {
// 由于模板参数T和U都没有从函数参数中推导出来,我们需要显式地指定它们
// 这里我们显式地指定T为int,U为double
printType<int, double>();
return 0;
}
typeid(T).name() 和 typeid(U).name()的用法主要是为了展示如何访问模板参数的类型信息,并且它们的输出格式可能会因编译器而异(特别是类型名称可能会是编译器特定的编码)。
当函数的返回类型必须与形参表中所用的所有类型都不同时时,std::common_type是一个非常有用的工具。可以帮助我们推导出合适的返回类型,无需额外的模板参数或显式的类型转换。
一种常见的解决方案是使用 std::common_type(在<type_traits>头文件中定义)来自动推导能够包含两个参数类型的最小公共类型。std::common_type模板在C++11及更高版本中可用,它可以自动推导出两个(或多个)类型的共同类型。
/*common_type使用示例*/
#include <type_traits>
template <class T, class U>
auto sum(T a, U b) -> typename std::common_type<T, U>::type {
return a + b;
}
// 使用示例
int main() {
auto result1 = sum(3, 4L); // 调用 long sum(int, long),返回 long
auto result2 = sum(3L, 4); // 调用 long sum(long, int),返回 long
return 0;
}
auto关键字和->操作符(尾置返回类型)一起使用,以允许我们在函数体内或之后确定返回类型。std::common_type<T, U>::type是推导出的共同类型,它足够大以容纳T和U类型的值。
在C++中,当模板函数的返回类型不能仅通过其参数类型推断出来时,需要显式指定模板实参。特别是在设计如sum这样的函数时,它接受两个不同类型的参数并需要返回一个足够大的类型来容纳这两个数的和,这个问题尤为突出。
解决方案
1. 引入第三个模板参数来指定返回类型
这允许调用者显式指定返回类型,而函数参数类型T和U可以从函数调用中推断出来。
/*显示指定返回类型*/
template <class T1, class T2, class T3>
T1 sum(T2 a, T3 b) {
return static_cast<T1>(a) + static_cast<T1>(b);
}
int main() {
long lng = 5L;
int i = 3;
long result = sum<long>(i, lng); // 显式指定返回类型为long
return 0;
}
static_cast 和 dynamic_cast 是 C++ 中用于类型转换的两个关键字,一个用于内置类型的安全转换,一个用于基类向派生类的安全转换。
2. 处理函数模板重载和二义性
当存在多个重载版本的函数模板时,并且这些模板仅通过模板参数类型区分,显式指定模板实参可以帮助编译器选择正确的函数模板。
/*显式指定模板实参避免二义性*/
template <typename T> int compare(const T&, const T&);
void func(int(*) (const std::string&, const std::string&));
void func(int(*) (const int&, const int&));
func(compare<int>); // 显式指定使用int版本的compare
在模板名称后使用尖括号<>指定模板参数的类型,这些值将覆盖模板参数的自动推断。
16.3. 模板编译模型
模板在C++中提供了一种编写泛型代码的方式,但与常规函数和类不同,模板的实例化需要编译器在编译时访问模板的完整定义。这导致了模板代码的组织方式有所不同,特别是关于头文件和源文件的布局。
模板实例化需求
模板定义访问:
当编译器遇到模板的实例化请求时(如函数模板调用或类模板对象创建),它需要访问模板的完整定义。
包含模型:
一种常见的处理方式是使用包含模型,其中模板的定义直接或通过#include指令在头文件中可用。这允许编译器在需要时访问定义。
分别编译模型(已废弃):
理论上,分别编译模型允许模板定义和声明分开,通过export关键字(现已在C++11及以后版本中废弃)来标记模板定义可能在其他文件中被实例化。然而,由于实现复杂性和编译器支持不一致,这一模型在实际中很少使用。
包含模型示例
在包含模型中,模板定义通常放在头文件中,或者通过头文件中的 #include指令包含进来。
#ifndef UTILITIES_H
#define UTILITIES_H
// 模板声明
template <class T> int compare(const T&, const T&);
// 包含模板定义
#include "utilities.cc"
#endif
源文件 utilities.cc(实际上可能被视为“.inl”或“.tpp”等后缀,以区分常规源文件)
/*注意,模板源文件后缀一般为.cc*/
template <class T>
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
16.4. 类模板成员
类模板成员是指在C++中使用类模板定义时,类内部包含的成员(包括数据成员和成员函数),这些成员可以使用模板参数类型进行声明和操作。
QueueItem 类模板
QueueItem是一个内部节点类,用于构建链表的节点,每个节点存储一个元素和一个指向下一个节点的指针。
QueueItem是一个私有类模板,只供Queue类使用。
template <class Type>
class QueueItem {
private:
Type item; // 存储的元素值
QueueItem* next; // 指向下一个节点的指针
// 私有构造函数,只能通过Queue类访问
QueueItem(const Type& t) : item(t), next(nullptr) {}
// 假设Queue是QueueItem的友元类,以便访问私有成员
friend class Queue<Type>;
};
Queue 类模板
Queue类模板实现了队列的基本操作,如入队(push)、出队(pop)、访问队首元素(front)以及检查队列是否为空(empty)。
它还包含了复制控制成员(构造函数、析构函数、赋值运算符)以管理内部节点。
template <class Type>
class Queue {
public:
// 构造函数
Queue() : head(nullptr), tail(nullptr) {}
// 复制构造函数
Queue(const Queue& Q) : head(nullptr), tail(nullptr) {
copy_elems(Q);
}
// 析构函数
~Queue() {
destroy();
}
// 赋值运算符(声明,具体实现在此处未给出)
Queue& operator=(const Queue&);
// 访问队首元素
Type& front() { return head->item; }
const Type& front() const { return head->item; }
// 入队操作(声明,具体实现在此处未给出)
void push(const Type& item);
// 出队操作(声明,具体实现在此处未给出)
void pop();
// 检查队列是否为空
bool empty() const { return head == nullptr; }
private:
QueueItem<Type>* head; // 指向队列头部的指针
QueueItem<Type>* tail; // 指向队列尾部的指针
// 实用函数(声明,具体实现在此处未给出)
void destroy();
void copy_elems(const Queue& Q);
};
16.4.1. 类模板成员函数
类模板 Queue 概述
类模板 Queue 是一个泛型队列实现,它使用链表来存储元素。
它包含几个关键成员函数,如 push(向队列末尾添加元素)、pop(从队列头部移除元素)、destroy(销毁队列中的所有元素)和 copy_elems(用于复制队列中的元素,通常用于复制构造函数和赋值操作符)。
成员函数定义
destroy 销毁队列中的所有元素,通过不断调用 pop 直到队列为空。
template <class Type>
void Queue<Type>::destroy() {
while (!empty()) pop();
}
pop 从队列头部移除一个元素,并释放相应的内存。
template <class Type>
void Queue<Type>::pop() {
if (!empty()) {
QueueItem<Type>* p = head;
head = head->next;
if (head == nullptr) tail = nullptr; // 如果队列为空,更新tail
delete p;
}
// 注意:这里没有处理空队列的情况,通常在实际应用中会抛出异常或断言
}
push 在队列末尾添加一个元素。如果队列为空,则新元素同时成为头尾元素。
template <class Type>
void Queue<Type>::push(const Type& val) {
QueueItem<Type>* pt = new QueueItem<Type>(val);
if (empty()) {
head = tail = pt;
} else {
tail->next = pt;
tail = pt;
}
}
copy_elems 从另一个队列复制所有元素到当前队列。首先销毁当前队列中的所有元素,然后遍历源队列,将每个元素添加到当前队列中。
template <class Type>
void Queue<Type>::copy_elems(const Queue<Type>& orig) {
destroy(); // 清除当前队列中的所有元素
for (QueueItem<Type>* pt = orig.head; pt; pt = pt->next) {
push(pt->item);
}
}
实例化
类模板的成员函数在需要时才会被实例化。
16.4.2. 非类型形参的模板实参
给一个使用非类型形参的类模板。例子。
类模板定义
/*类模板,使用非类型形参*/
template <int hi, int wid>
class Screen {
public:
// 构造函数,使用模板形参初始化数据成员
Screen() : screen(hi * wid, '#'), cursor(0), height(hi), width(wid) {}
// 其他成员函数...
private:
std::string screen;
std::string::size_type cursor;
int height, width; // 注意这里直接使用int类型,因为模板形参就是int
};
使用类模板
要使用这个Screen类模板,需要在声明对象时提供具体的模板实参(这里是高度和宽度的值),这些实参必须是编译时常量表达式。
Screen<24, 80> hp2621; // 声明一个Screen对象,其高度为24,宽度为80
总结
非类型模板形参允许你在编译时将常量值传递给模板。
在定义模板时,你可以像定义常规类成员变量一样使用这些形参。
使用模板时,必须显式提供模板实参。
16.4.3. 类模板中的友元声明
在类模板中声明友元时,可以根据需要授予不同类型的实体对模板类成员的访问权限。
这些友元声明分为三种主要类型:
普通非模板友元、一般模板友元和特定模板实例友元。
1. 普通非模板友元
非模板类或非模板函数可以是类模板的友元。
class FooBar; // 非模板类
void fcn(); // 非模板函数
template <class Type>
class Bar {
friend class FooBar; // 授予 FooBar 访问权限
friend void fcn(); // 授予 fcn 函数访问权限
};
2. 一般模板友元关系
友元可以是类模板或函数模板,其类型参数与类模板的类型参数可以不同。
template <class T> class Foo1; // 类模板
template <class T> void templ_fcn1(const T&); // 函数模板
template <class Type>
class Bar {
template <class U> friend class Foo1; // 授予 Foo1 所有实例访问权限
template <class U> friend void templ_fcn1(const U&); // 授予 templ_fcn1 所有实例访问权限
};
3. 特定模板友元关系
可以仅将模板的特定实例设为友元,这通过明确指定模板参数来实现。这限制了只有特定类型的模板实例才能访问模板类的成员。
template <class T> class Foo2;
template <class T> void templ_fcn2(const T&);
template <class Type>
class Bar {
friend class Foo2<char*>; // 仅授予 Foo2<char*> 访问权限
friend void templ_fcn2<char*>(char* const &); // 仅授予 templ_fcn2<char*> 访问权限
};
// 另一个常见的特定实例友元声明,每个 Bar 实例授予相同类型参数的 Foo3 或 templ_fcn3 访问权限
template <class Type>
class Bar2 {
friend class Foo3<Type>; // 授予 Foo3<Type> 访问权限
friend void templ_fcn3<Type>(const Type&); // 授予 templ_fcn3<Type> 访问权限
};
当声明特定模板实例为友元时,需要在声明之前确保该类或函数模板已被声明为模板。否则,编译器会将其视为非模板类或非模板函数。
template <class T> class A;
template <class T> class B {
friend class E<T>; // 错误:E 未被声明为模板
};
// 正确声明
template <class T> class E;
template <class T> class B {
friend class E<T>; // 正确
};
16.4.4. 成员模板
成员模板简介
成员模板允许类模板拥有可以接受不同类型参数的成员函数或构造函数。这些成员模板不能被声明为虚函数。成员模板通常用于实现如标准容器中的 assign方法和构造函数,这些方法可以接受来自不同容器或序列的元素。
成员模板定义
成员模板的定义与类模板的定义类似,但需要额外的模板参数列表来定义成员模板的参数。
template <class Type> class Queue {
public:
// 构造函数模板,接受两个迭代器
template <class It>
Queue(It beg, It end) : head(0), tail(0) {
copy_elems(beg, end);
}
// assign 成员函数模板,也接受两个迭代器
template <class Iter>
void assign(Iter beg, Iter end) {
destroy(); // 假设已定义,用于销毁当前队列中的元素
copy_elems(beg, end); // 从迭代器范围复制元素
}
private:
// 辅助成员函数模板,用于实际复制操作
template <class It>
void copy_elems(It beg, It end) {
while (beg != end) {
push(*beg); // 假设 push 成员函数已定义
++beg;
}
}
// 其他成员变量和成员函数...
int head, tail; // 假设队列用数组实现,head 和 tail 指向队首和队尾
};
在类外部定义成员模板
当在类外部定义成员模板时,需要同时指定类模板和成员模板的模板参数。
template <class T>
template <class Iter>
void Queue<T>::assign(Iter beg, Iter end) {
// 实现同上
}
template <class Type>
template <class It>
void Queue<Type>::copy_elems(It beg, It end) {
// 实现同上
}
成员模板的实例化
成员模板的实例化发生在它们被实际使用时。编译器会根据实参类型推断模板参数的类型。
short a[4] = {0, 3, 6, 9};
Queue<int> qi(a, a + 4); // 实例化 Queue<int>::Queue(short*, short*)
vector<int> vi(a, a + 4);
qi.assign(vi.begin(), vi.end()); // 实例化 Queue<int>::assign(vector<int>::iterator, vector<int>::iterator)
16.4.5. 类模板的 static 成员
类模板中的静态成员
在类模板中,可以定义静态成员(包括静态数据成员和静态成员函数)。这些静态成员对于模板的特定实例化是共享的,即所有相同类型的模板实例都共享同一个静态成员。
template <class T>
class Foo {
public:
static std::size_t count() { return ctr; } //静态成员函数,对所有实例共享
// 其他成员函数
private:
static std::size_t ctr; //静态数据成员声明,静态数据成员定义和初始化只能在外部
// 其他数据成员
};
静态成员函数的使用:可以通过实例化对象或类模板名(指定类型参数)来访问静态成员函数。
Foo<int> fi, fi2;
std::size_t ct = Foo<int>::count(); // 正确
ct = fi.count(); // 正确,等同于 Foo<int>::count()
// 注意:Foo::count(); // 错误,因为编译器不知道是哪个类型的实例化
静态数据成员的定义:必须在类模板外部定义静态数据成员,并指明它属于哪个模板实例化。
template <class T>
std::size_t Foo<T>::ctr = 0; // 定义并初始化ctr
16.5. 一个泛型句柄类
16.5.1. 定义句柄类
Handle类设计概述
模板类:
Handle<T>是一个模板类,用于管理类型为T(或其派生类型)的对象的指针。
引用计数:
每个Handle对象都维护一个指向其管理对象的指针和一个使用计数(use),以跟踪有多少Handle对象指向该对象。
资源管理:
当最后一个Handle对象被销毁时,它负责删除所管理的对象。
指针行为:
通过重载解引用操作符*和成员访问操作符->,Handle对象的行为类似于指针。
安全检查:
在尝试访问未绑定(即ptr为nullptr)的Handle时,会抛出异常。
#include <stdexcept>
#include <cstdlib> // for std::size_t and std::exit
template <class T>
class Handle {
public:
// 默认构造函数(未绑定)
Handle(T* p = nullptr) : ptr(p), use(new std::size_t(1)) {}
// 复制构造函数
Handle(const Handle& h) : ptr(h.ptr), use(h.use) { ++*use; }
// 赋值操作符
Handle& operator=(const Handle& rhs) {
if (this != &rhs) { // 防止自赋值
rem_ref(); // 减少当前对象的使用计数,并可能需要删除对象
ptr = rhs.ptr;
use = rhs.use;
++*use; // 增加右侧对象的使用计数
}
return *this;
}
// 析构函数
~Handle() { rem_ref(); }
// 解引用操作符(非const版本)
T& operator*() {
if (!ptr) throw std::runtime_error("dereference of unbound Handle");
return *ptr;
}
// 成员访问操作符(非const版本)
T* operator->() {
if (!ptr) throw std::runtime_error("access through unbound Handle");
return ptr;
}
// const版本的解引用和成员访问操作符(此处省略,类似但返回const类型)
private:
T* ptr; // 指向对象的指针
std::size_t* use; // 使用计数
// 辅助函数:减少使用计数,并在必要时删除对象
void rem_ref() {
if (--*use == 0) {
delete ptr;
delete use;
}
}
// 注意:const版本的成员函数和操作符应被适当实现以处理const Handle对象
};
// 注意:为了完整性和安全性,还需要实现const版本的解引用和成员访问操作符
// 以及可能的比较操作符和转换到bool的操作符等。
16.5.2. 使用句柄
Sales_item类现在使用Handle<Item_base>作为其内部存储机制,以管理Item_base对象的生命周期和复制。
这样做的好处是Sales_item类不再需要显式的复制控制成员(复制构造函数、赋值操作符和析构函数),因为Handle类已经处理了这些任务。
Sales_item类通过其接口(如*和->操作符)将操作转发给内部的Handle对象。
/*句柄的简化版本*/
template <class T>
class Handle {
// ... Handle类的实现(略)
};
// 假设Item_base是一个基类,具有clone()等成员函数
class Item_base {
public:
virtual ~Item_base() {}
virtual Item_base* clone() const = 0;
// ... 其他成员函数
};
// 假设Item_base有派生类,并且它们实现了clone()函数
//Sales_item类的实现:
#include <iostream>
#include <vector>
#include <algorithm> // 用于std::upper_bound
#include <map>
// Sales_item类使用Handle来管理Item_base对象的生命周期
class Sales_item {
public:
// 默认构造函数:创建一个未绑定的Handle
Sales_item() : h() {}
// 构造函数:接受一个Item_base的const引用,并创建一个副本,Handle绑定到这个副本
Sales_item(const Item_base& item) : h(item.clone()) {}
// 无需定义复制控制成员,因为Handle会处理它们
// 成员访问操作符:转发给Handle
const Item_base& operator*() const { return *h; }
const Item_base* operator->() const { return h.operator->(); }
// 注意:这里没有非const版本的*和->操作符,因为Sales_item不允许修改它所引用的对象
private:
Handle<Item_base> h; // 使用计数的Handle
};
// 假设Basket类使用Sales_item的vector
class Basket {
// ... 其他成员和函数
// 计算总价
double total() const {
double sum = 0.0;
for (const auto& iter : items) {
auto end = items.upper_bound(iter);
sum += iter->net_price(std::distance(items.begin(), end));
}
return sum;
}
std::multimap<Sales_item, size_t> items; // 假设items存储Sales_item和数量
};
// 注意:上述Basket::total()函数中的代码假设items是一个multimap,
// 其中键是Sales_item对象,值是数量。这在实际应用中可能需要调整。
// 示例用法(假设Item_base和派生类已经定义并可用)
// ...
16.6. 模板特化
对于某些特定类型,通用模板可能不适用或效率低下,因此需要提供特化版本,这些特化版本对模板用户是透明的,确保了函数或类的正确性和效率。
16.6.1. 函数模板的特化
模板特化
模板特化允许你为模板的特定类型或值提供专门的实现。这通常用于优化性能或处理模板在特定类型上的默认行为不足的情况。
特化形式
模板特化使用 template<> 语法,后跟模板名和模板参数列表(指定了特定类型或值)。
/*示例:C风格字符串的compare特化*/
// 通用模板定义
template <typename T>
int compare(const T& v1, const T& v2) {
// 通用实现
}
// 特化定义,处理 const char* 类型
template <>
int compare<const char*>(const char* const &v1, const char* const &v2) {
return strcmp(v1, v2);
}
模板特化声明
模板特化也可以只声明而不定义,这需要在 template<> 后指定模板参数,但省略函数体。
// 特化声明
template<>
int compare<const char*>(const char* const&, const char* const&);
函数重载与模板特化
省略 template<> 声明的是非模板函数的重载,而非模板特化。
特化版本的调用要求实参类型与特化版本的形参类型完全匹配。
16.6.2. 类模板的特化
C 风格字符串与 Queue 类的问题
当 Queue 类用于存储 C 风格字符串(const char*)时,会遇到与 compare 函数相似问题。
具体来说,Queue 的 push 函数默认会复制指针而非字符串本身,这可能导致多个 Queue 元素共享同一个字符串内存,进而引发内存管理问题(如双重释放)。
解决方案:为 const char* 特化 Queue 类
为了解决这个问题,可以为 const char* 特化 Queue 类,使其内部使用 std::string 来存储字符串,从而避免直接操作指针。
特化 Queue 类
特化版本的 Queue 类将使用 Queue<std::string> 作为内部存储,并通过转换函数将 const char* 转换为 std::string。
template<>
class Queue<const char*> {
public:
// 成员函数,无需定义复制控制成员
void push(const char* val) { real_queue.push(std::string(val)); }
void pop() { real_queue.pop(); }
bool empty() const { return real_queue.empty(); }
// 修改 front 函数的返回类型为 std::string
std::string front() { return real_queue.front(); }
const std::string& front() const { return real_queue.front(); }
private:
Queue<std::string> real_queue; // 使用 string 类型的 Queue 存储字符串
};
16.6.3. 特化成员而不特化类
为了处理 const char* 类型的字符串,也可以只特化 Queue 模板的 push 和 pop 成员函数,以便在 push 时复制字符串并在 pop 时释放内存。这样,我们可以避免直接操作指针,减少内存管理错误的风险。
// 假设 Queue 类和 QueueItem 类的基本结构已经定义
// 特化 push 成员函数
template <>
void Queue<const char*>::push(const char* const &val) {
char* new_item = new char[strlen(val) + 1];
strcpy(new_item, val); // 使用 strcpy 而不是 strncpy,因为我们已经计算了长度
QueueItem<const char*>* pt = new QueueItem<const char*>(new_item);
if (empty()) {
head = tail = pt;
} else {
tail->next = pt;
tail = pt;
}
}
// 特化 pop 成员函数
template <>
void Queue<const char*>::pop() {
if (!empty()) {
QueueItem<const char*>* p = head;
delete[] head->item; // 注意这里使用 delete[] 来释放字符数组
head = head->next;
delete p;
}
// 可以选择添加对空队列的处理逻辑
}
// 特化声明(通常放在头文件中)
template <>
void Queue<const char*>::push(const char* const &);
template <>
void Queue<const char*>::pop();
16.6.4. 类模板的部分特化
类模板的部分特化允许我们针对模板参数的一个或多个子集进行特化,而不是必须特化所有模板参数。
类模板的部分特化
当类模板有多个模板参数时,我们可以部分特化其中一些参数,而让其他参数保持通用。部分特化通过为部分已知的模板参数提供特定的模板定义来实现。
有两个模板参数的类模板 some_template:
template <class T1, class T2>
class some_template {
// 通用模板定义
};
我们可以部分特化这个模板,将 T2 固定为 int,而让 T1 保持通用:
// 部分特化:将 T2 固定为 int,T1 保持通用
template <class T1>
class some_template<T1, int> {
// 部分特化定义
};
实例化
当实例化 some_template 时,编译器会根据提供的模板参数选择最合适的模板定义:
some_template<int, std::string> foo; // 使用通用模板定义
some_template<std::string, int> bar; // 使用部分特化定义
16.7. 重载与函数模板
函数模板重载与二义性
函数模板可以重载,即可以定义多个具有相同名称但模板参数或类型不同的模板函数。同时,也可以定义与模板函数同名的普通非模板函数。重载的函数模板可能导致调用时的二义性。
template <typename T> int compare(const T&, const T&);
template <class U, class V> int compare(U, U, V);
int compare(const char*, const char*);
/*
调用示例
compare(1, 0); 调用第一个模板,T绑定到int。
compare(ivec1.begin(), ivec1.end(), ivec2.begin()); 调用第二个模板,U和V分别绑定到vector<int>::iterator。
compare(const_arr1, const_arr2); 调用普通函数,因为完全匹配且优先于模板。
compare(ch_arr1, ch_arr2); 同样调用普通函数,尽管模板也匹配,但普通函数优先。
*/
参数完全匹配时,普通函数优先于模板函数。
转换与重载
当设计包含模板和非模板的重载函数时,需要特别注意隐式转换。例如,传递数组和指针到模板函数时可能产生不同的行为,因为数组名在大多数情况下会退化为指向其首元素的指针。
如果模板函数接受非const引用,则传递数组或指针时都会调用模板版本,因为数组名退化为指针,且没有从char*到const char*的隐式转换来优先调用非模板函数。
template <typename T> int compare(T, T); // 注意这里是非const引用
int compare(const char*, const char*);
char ch_arr1[] = "hello";
char *p1 = ch_arr1;
// 调用模板版本,因为数组名退化为指针
compare(ch_arr1, ch_arr1);
// 同样调用模板版本
compare(p1, p1);
// 调用非模板版本,因为完全匹配
compare("hello", "world");
const对象和非const对象之间可以互转。但是const指针和非const指针不能互转。