上一章是类与对象(4)-优快云博客 深入了构造函数和静态成员,大概讲解了类型转换
最后一章最后一章
友元
在 C++ 中,友元提供了一种突破类的访问控制(封装)的方式。通过友元,外部的函数或类可以访问类的私有成员和保护成员。友元分为友元函数和友元类。在类声明中使用 friend
关键字来声明友元。
友元函数
-
定义:友元函数并不是类的成员函数,但它可以访问类的私有和保护成员。友元函数是在类的外部定义的独立函数,通过
friend
声明,告诉编译器这个函数可以访问类的私有成员。 -
声明位置:友元函数的声明位置可以在类定义中的任何地方。它不受类的访问权限(如
private
、protected
、public
)的限制,可以在类定义中的任意位置声明。 - 作用:友元函数的主要作用是允许外部函数访问类的私有成员,从而为一些特殊的需求提供便利。由于它不是类的成员函数,所以它不属于类的一部分,但是它拥有对类私有成员的访问权限。
class Box {
private:
int length;
public:
Box() : length(0) {}
// 友元函数声明
friend void setLength(Box& b, int len);
};
// 友元函数定义
void setLength(Box& b, int len) {
b.length = len; // 可以访问 Box 类的私有成员
}
- 多重友元:一个函数可以是多个类的友元函数。例如,
func1
可以是ClassA
和ClassB
的友元函数,可以同时访问这两个类的私有成员。
class ClassA;
class ClassB;
void func1(ClassA& a, ClassB& b); // 友元函数可以访问多个类的私有成员
友元类
-
定义:友元类是一个类,它可以访问另一个类的私有和保护成员。当一个类是另一个类的友元类时,友元类的所有成员函数都有权访问被友元类的私有成员。友元类的关系也是单向的,即如果
ClassA
是ClassB
的友元类,那么ClassB
并不是ClassA
的友元类。 -
关系:友元类的成员函数可以访问另一个类的私有成员,意味着友元类可以通过它的成员函数来操作其他类的私有数据。即使
ClassA
和ClassB
中有相同的私有成员,友元类的成员函数仍然可以直接访问。
class ClassB;
class ClassA {
private:
int dataA;
public:
ClassA() : dataA(0) {}
// 将 ClassB 作为友元类
friend class ClassB;
};
class ClassB {
public:
void modify(ClassA& a) {
a.dataA = 10; // 可以访问 ClassA 的私有成员
}
};
友元关系的特点
-
单向性:友元关系是单向的。比如,
ClassA
是ClassB
的友元类,但这并不意味着ClassB
也是ClassA
的友元类。要实现双向友元关系,必须显式地将另一个类声明为友元。 -
不可传递性:友元关系是不可传递的。即使
ClassA
是ClassB
的友元,ClassB
又是ClassC
的友元,这并不意味着ClassA
是ClassC
的友元。友元关系不能像继承一样传递。 -
友元函数和友元类的访问权限:友元函数和友元类的成员函数可以访问类的私有成员和保护成员。虽然它们不是类的成员,但它们被授权访问类内部的实现细节。
-
增加耦合度:友元关系虽然可以提供便利,但也会使得类之间的耦合度增加。特别是当多个类间相互成为友元时,会破坏类的封装性,因为这会使得类的内部实现细节暴露给外部的类或函数。
因此,尽量避免滥用友元,应该仅在确实需要的情况下才使用它。友元函数和友元类往往用于某些特定的场景,比如操作符重载、类之间的协作等。
为什么使用友元
-
访问权限的突破:友元最主要的功能就是突破类的访问权限控制,使得其他函数或类可以访问类的私有成员。这在某些特殊情况下非常有用,尤其是当你需要在类外部操作类的私有数据时。
-
实现操作符重载:在 C++ 中,很多操作符重载(如
<<
和>>
输入输出流操作符)通常需要将类定义为友元,以便能够访问类的私有成员。
友元的使用建议
尽管友元为我们提供了方便的功能,但它也会增加类之间的耦合度,破坏封装性。因此,友元函数和友元类的使用要谨慎,避免过度依赖友元。如果可能,尽量通过类的公有接口来访问类的私有数据,而不是使用友元。
内部类
内部类是在一个类的内部定义的类,它和普通类相比主要有一些特殊的作用域和访问权限规则。通过将一个类定义在另一个类的内部,可以增加封装性,使得类与类之间的关系更加紧密。以下是对内部类的详细讲解:
定义和特点
-
独立性:内部类本质上是一个独立的类,它有自己的作用域和生命周期。它不像外部类那样受到全局作用域的限制,而是仅限于外部类的作用域内。
尽管它被定义在外部类的内部,内部类依然是一个完整的类,与外部类没有直接的继承或包含关系。例如,外部类
A
内部定义的类B
,B
仍然是一个独立的类。 -
访问限定符限制:内部类受外部类的访问限定符(
private
、protected
、public
)的影响。如果外部类是private
,那么内部类也会受到同样的限制;如果外部类是public
,那么内部类的访问限制会根据其自身的声明来决定。 -
外部类不包含内部类:外部类的对象中并不会包含内部类对象,除非明确实例化内部类的对象。内部类是外部类的一个成员,但外部类并没有包含内部类作为其成员变量的一部分。
内部类的访问权限
-
默认友元类:内部类默认是外部类的友元类,这意味着内部类可以访问外部类的所有私有成员和保护成员。反过来,外部类无法直接访问内部类的私有成员,除非通过公开的接口。
例如:
class Outer { private: int x; public: Outer() : x(10) {} class Inner { public: void accessOuter(Outer& o) { // 内部类访问外部类的私有成员 cout << "Accessing Outer's private member x: " << o.x << endl; } }; }; int main() { Outer::Inner inner; Outer outer; inner.accessOuter(outer); // 访问外部类的私有成员 return 0; }
-
作用域控制:内部类只能在外部类的作用域中被访问,这使得它只能在外部类中使用,增强了封装性。其他类无法直接访问或创建外部类内部的类。
设计上的优势
-
封装性:内部类常用于增强封装。当两个类之间有非常紧密的关系时,把一个类设计为另一个类的内部类是非常合适的。这种设计可以把一个类完全限制在另一个类的作用域中,只在必要的时候才提供外部访问。
-
专属类设计:如果你有一个类,它只会被另一个类使用,且不需要外部其他地方访问时,可以将其作为内部类进行设计。例如,
B
类仅为A
类提供某些服务,那么就可以把B
类设计成A
类的内部类,而不暴露给外部。 -
类之间的紧密联系:内部类的设计使得两个类之间的关系更加紧密。
A
类和B
类不再是松散的独立实体,它们有着更加明确的隶属关系。比如,你可以将B
类的成员限制为只在A
类的作用域内使用,避免了外部使用时的误操作。
内部类的常见用法
- 辅助类的设计:当一个类需要一个辅助类来完成一些特定任务,但这个辅助类并不需要被外部访问时,可以考虑将其设计为内部类。
class Outer {
public:
class Helper {
public:
void helperFunction() {
cout << "Helper Function!" << endl;
}
};
};
int main() {
Outer::Helper helper;
helper.helperFunction(); // 只能通过外部类访问内部类
return 0;
}
- 面向对象设计中的迭代器模式:内部类常常在容器类设计中使用,例如,设计一个迭代器类作为内部类,以便访问容器的私有数据。
class Container {
private:
vector<int> data;
public:
class Iterator {
private:
vector<int>::iterator it;
public:
Iterator(vector<int>::iterator iterator) : it(iterator) {}
int operator*() { return *it; }
Iterator& operator++() { ++it; return *this; }
bool operator!=(const Iterator& other) { return it != other.it; }
};
Container() {
data.push_back(1);
data.push_back(2);
data.push_back(3);
}
Iterator begin() { return Iterator(data.begin()); }
Iterator end() { return Iterator(data.end()); }
};
int main() {
Container c;
for (auto it = c.begin(); it != c.end(); ++it) {
cout << *it << " "; // 通过迭代器访问容器数据
}
return 0;
}
内部类的注意点
-
与外部类紧密耦合:内部类和外部类之间有较强的耦合性。如果外部类发生变化,内部类也可能需要做相应的调整。因此,在设计时需要考虑这种耦合度是否符合预期的设计目标。
-
访问控制:尽管内部类可以访问外部类的私有成员,但外部类不能直接访问内部类的私有成员。如果你希望外部类也能访问内部类的成员,可以提供适当的公有接口。
-
内存管理:如果内部类和外部类的对象关系密切,需要特别注意内存管理问题,避免出现不必要的内存泄漏或引用问题。
匿名对象
在编程中,匿名对象是没有名字的临时对象。相比传统的有名字的对象(比如“类型 对象名(实参)”),匿名对象通过“类型(实参)”的方式被临时创建。这种方式极大地简化了代码,避免了不必要的命名,同时也能清晰地表达我们只关心对象的短期行为,而不需要后续访问它。
特点:
-
短生命周期:匿名对象的生命周期异常短暂,它们只存在于当前的代码行内。换句话说,它们就像是昙花一现,创建后立刻完成任务并销毁,不会占用额外的内存或资源。对于只需要执行一次特定任务的情况,匿名对象完美适用。
-
临时性与独立性:匿名对象没有名称,它们是孤立的,不会与程序中的其他部分发生联系。它们的作用通常仅限于创建它们的那一行代码,在这一行结束后,它们会立刻被销毁。这种“即生即死”的特性让它们非常适合于一次性的操作。
class Person {
public:
Person(const std::string& name) {
std::cout << "Creating Person: " << name << std::endl;
}
~Person() {
std::cout << "Destroying Person." << std::endl;
}
};
int main() {
// 使用匿名对象创建并立即执行操作
Person("Alice");
// 匿名对象在这一行结束后销毁
return 0;
}
在上面的代码中,Person("Alice")
是一个匿名对象。它在这行代码执行时创建,完成打印操作后,它就会立即销毁。整个过程没有变量名,也不需要开发者去管理它的生命周期,C++ 会自动处理。
匿名对象的实际用途:
-
简洁高效:有时你只需要临时创建一个对象来完成某项任务,匿名对象能够简洁地实现这一点。例如,你可能只是需要创建一个对象传递给某个函数,而不需要在整个程序中反复使用它。这样就不需要定义多余的变量名,代码既简洁又清晰。
例如,我们常见的流操作中,经常用到匿名对象:
std::cout << "Hello, World!" << std::endl;
这里的
std::cout
和std::endl
都是匿名对象,创建后立即使用,之后会被销毁。
- 临时传参:当函数需要传递一个临时对象时,匿名对象尤其有用。例如,构造函数或方法可能需要接受一个临时对象作为参数,匿名对象正好可以完成这一任务,而无需为对象额外命名。
class Rectangle {
public:
Rectangle(int width, int height) {
std::cout << "Creating Rectangle with width " << width << " and height " << height << std::endl;
}
};
void printArea(const Rectangle& rect) {
// 输出面积
}
int main() {
// 直接传递匿名对象
printArea(Rectangle(5, 10));
}
这里,Rectangle(5, 10)
就是一个匿名对象,它仅仅为了传递给 printArea
方法而创建,之后立即销毁。
匿名对象的局限性:
-
生命周期有限:匿名对象的生命周期极为短暂,它们无法在代码的其他地方使用。一旦当前代码行执行完毕,匿名对象即刻销毁,内存被回收。因此,如果你想在其他地方访问该对象的数据或方法,显然匿名对象无法满足需求。
-
不可引用或修改:由于匿名对象没有名称,你无法像有名对象那样通过指针或引用来操作它们。如果你的代码需要反复操作一个对象,或者保持该对象的状态,使用匿名对象则不合适。
对象拷贝时的编译器优化
在现代 C++ 编译器中,优化对象拷贝的过程是提高程序性能的重要手段。尤其是在对象传递和返回时,拷贝操作可能是一个性能瓶颈。为了提高效率,编译器会尽量避免不必要的对象拷贝,尤其是在不影响程序正确性的情况下,进行一些“聪明”的优化。
编译器优化的基本原则:
编译器的目标是尽量减少不必要的拷贝操作,提高程序的执行效率。在某些情况下,拷贝操作可能是多余的,编译器会在编译阶段识别这些冗余的拷贝并尝试消除它们。这些优化主要集中在以下几个方面:
-
避免临时对象的拷贝:如果一个函数的参数是传值形式,并且传递给函数的实参是一个临时对象,编译器可能会识别到这个临时对象在函数结束后并不再使用,从而避免了不必要的拷贝。
-
合并拷贝:现代编译器会识别出在同一个表达式中连续发生的多个拷贝操作,并尝试将它们合并,减少拷贝的次数。这种优化会在表达式的计算过程中自动完成,而不需要程序员干预。
-
跨行跨表达式的优化:一些更“激进”的编译器会进行跨行或跨表达式的优化。即使是在不同代码行或表达式中发生的对象拷贝,编译器也可能会发现这些拷贝是冗余的,并将它们合并成一次有效的拷贝操作。
示例:合并拷贝的优化
class Person {
public:
Person(const std::string& name) : name(name) {}
std::string name;
};
Person createPerson() {
return Person("Alice");
}
int main() {
Person p1 = createPerson(); // 对象拷贝1
Person p2 = p1; // 对象拷贝2
return 0;
}
在这段代码中,我们有两个对象拷贝:
- 第一个拷贝发生在
createPerson()
函数返回时。这里返回了一个临时对象,通常会发生一次拷贝。 - 第二个拷贝发生在
p2 = p1
时,这又是一次拷贝。
在没有优化的情况下,编译器可能会执行两次拷贝。然而,现代编译器(例如 GCC、Clang、MSVC)会进行优化,合并这些拷贝。具体的优化方式可能包括:
- 使用 返回值优化(RVO) 或 命名返回值优化(NRVO) 来避免
createPerson()
函数中的临时对象拷贝。 - 对于
p2 = p1
,如果编译器发现p1
和p2
实际上是完全相同的对象(无副作用的赋值),它可能会直接将p1
的内容移动到p2
,从而避免拷贝。
进一步的优化:移动语义
随着 C++11 引入的移动语义,编译器在拷贝和赋值时可以进一步优化。例如,使用 std::move
或者编译器能够自动识别某些场景并将拷贝操作转换为移动操作,从而减少不必要的数据复制。
Person createPerson() {
return Person("Alice"); // 返回临时对象
}
int main() {
Person p1 = createPerson(); // 返回值优化 (RVO),避免拷贝
Person p2 = std::move(p1); // 移动构造函数,避免拷贝
return 0;
}
在这种情况下,std::move(p1)
会将 p1
的资源“转交”给 p2
,而不需要拷贝数据,从而进一步提高性能。
编译器优化的实现
不同的编译器可能有不同的优化策略,编译器在拷贝操作时进行的优化可能有所不同。主流编译器如 GCC、Clang 和 MSVC 都有一定的优化能力,能够识别不必要的拷贝,并在可能的情况下进行合并和消除。然而,C++ 标准并没有明确规定编译器应该如何优化对象拷贝,因此不同编译器之间的实现可能会有所差异。
- GCC 和 Clang:这两个编译器非常注重性能,它们有能力通过 RVO、NRVO 和移动语义进行深度优化,甚至跨表达式、跨行合并拷贝。
- MSVC:微软的编译器也具备类似的优化能力,特别是在进行 STL 容器操作时,MSVC 的编译器在拷贝构造和赋值操作中有显著的优化。
结束!