前言
为什么C++要学习类?学习C++中的类是掌握面向对象编程的关键。类提供了将数据与操作封装在一起的结构化方式,帮助开发者解决复杂问题、提高代码的可重用性和安全性。通过类的继承、封装、多态,可以更灵活地设计和扩展程序,同时模拟现实世界中的对象和行为,提升代码的可维护性和效率。掌握类有助于编写高效、清晰的代码,并应对复杂的软件系统设计。下面就由我来带大家深度剖析一下类和对象的真正奥秘…
【本节目标】
- 1.面向过程和面向对象初步认识
- 2.类的引入
- 3.类的定义
- 4.类的访问限定符及封装
- 5.类的作用域
- 6.类的实例化
- 7.类的对象大小的计算
- 8.类成员函数的this指针
1.面向过程和对象初步认识
1.1 面向过程编程
面向过程编程(Procedural Programming)是一种以过程(或函数)为中心的编程范式。在这种编程方式中,程序主要由一系列指令和函数构成,程序员通过函数调用来组织代码,函数的核心目的是处理数据。
- 特点:
- 以功能为中心:通过函数(也称为过程)来实现特定的功能。
- 数据和操作分离:数据和操作处理是分开的,函数主要处理全局变量或通过参数传递的数据。
- 线性执行:程序流程往往是自上而下、按顺序执行的。
- 优点:
- 简单直接,易于理解。
- 在小型程序或只处理简单任务时,面向过程编程通常更为高效。
- 缺点:
- 当程序规模增大时,函数之间的依赖和全局数据的管理变得复杂,维护难度增加。
- 缺乏对现实世界对象的建模能力,不易扩展。
- C++中的面向过程编程:C++继承自C语言,因此可以使用面向过程编程风格。你可以定义函数、全局变量、结构体等,来实现程序的功能。如下是一个简单的例子:
#include <iostream> // 函数定义 int add(int a, int b) { return a + b; } int main() { int result = add(5, 10); std::cout << "结果是: " << result << std::endl; return 0; }
1.2 面向对象编程
面向对象编程(Object-Oriented Programming, OOP)是C++的核心编程范式之一。它强调使用“对象”来组织代码,程序中的数据和操作被封装在对象中,具有更好的模块化和重用性。
- 特点:
- 封装:将数据和操作数据的函数封装在一个对象中,对外只暴露必要的接口,隐藏内部实现。
- 继承:一个类可以从另一个类继承属性和行为,便于代码复用和扩展。
- 多态:不同的对象可以通过相同的接口调用不同的实现,这种特性称为多态性。
- 以对象为中心:程序由对象之间的交互组成,每个对象对应现实世界中的实体。
- 优点:
- 提高了代码的可维护性和可扩展性,适合大型程序开发。
- 有助于建模复杂的现实世界问题。
- 通过封装提高了代码的安全性,降低了代码出错的几率。
- 缺点:
- 对于简单任务可能显得过于复杂。
- 程序的设计阶段更加复杂,学习曲线较陡峭。
- C++中的面向对象编程:C++支持类和对象的概念,以下是一个简单的面向对象编程的例子:
#include <iostream> // 定义一个类 class Rectangle { private: int width, height; public: // 构造函数 Rectangle(int w, int h) { width = w; height = h; } // 成员函数 int area() { return width * height; } }; int main() { // 创建对象 Rectangle rect(10, 20); // 调用成员函数 std::cout << "矩形的面积是: " << rect.area() << std::endl; return 0; }
总结:
- 面向过程编程侧重于函数调用,适合处理简单任务或小型程序。
- 面向对象编程通过封装、继承和多态等特性,提供了更好的代码组织方式,适合复杂的大型应用程序。
C++作为一门强大的编程语言,能够灵活地支持这两种编程方式,帮助开发者根据不同的需求选择合适的编程范式。
2.类的引入
类(Class)可以被看作是一个蓝图或模板,它定义了某种对象的属性(数据成员)和行为(成员函数)。通过类,我们可以创建具体的对象。
在C++中,类是通过关键字
class
定义的。一个类可以包含:
- 数据成员(成员变量):用于描述对象的属性。
- 成员函数:用于定义对象的行为。
3.类的定义
在C++中,类的定义包括类的声明和成员的实现。下面是一个类的基本定义示例:
#include <iostream> using namespace std; // 类的定义 class Person { private: // 私有成员变量 string name; int age; public: // 构造函数,用于初始化对象 Person(string n, int a) { name = n; age = a; } // 公有成员函数,用于访问和修改对象的属性 void introduce() { cout << "我叫 " << name << ",今年 " << age << " 岁。" << endl; } // 修改年龄的函数 void setAge(int newAge) { age = newAge; } }; int main() { // 创建对象 Person person1("小明", 20); // 调用成员函数 person1.introduce(); // 修改年龄 person1.setAge(22); // 再次调用成员函数 person1.introduce(); return 0; }
4.类的访问限定符及封装
4.1 访问修饰符
类中的成员可以通过访问修饰符进行访问控制,常见的修饰符有:
public
:公有成员,外部代码可以访问。private
:私有成员,外部代码无法直接访问,只能通过类的公有函数访问。protected
:受保护成员,外部代码无法访问,但可以在派生类中访问。构造函数:构造函数是用于初始化对象的特殊成员函数。构造函数的名字与类名相同,它在创建对象时自动调用。可以定义多个构造函数以支持不同的初始化方式。
析构函数:析构函数用于在对象销毁时执行清理工作,它的名称是类名前加一个波浪号
~
,通常用于释放资源(如内存或文件句柄)。
4.2 类的封装性
类的一个重要特点是封装,它通过将数据和操作数据的函数放在一起,确保对象的内部状态只能通过定义好的接口访问和修改。通过封装,可以更好地控制程序的复杂性,确保对象内部状态的完整性。
在上面的例子中,
name
和age
是类的私有成员变量,不能被外部直接访问。它们只能通过introduce()
和setAge()
这样的公有成员函数来操作,从而保证了数据的安全性。
5.类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类外定义成员时,需要使用::作用域操作符指明成员属于哪个类域。
数据成员作用域:类中的变量(成员变量)只能在类的成员函数内部或通过对象进行访问,具体取决于访问控制修饰符。
成员函数作用域:类中的成员函数可以访问类的所有成员(包括私有成员和保护成员),并且可以在对象创建后通过对象来调用。
5.1类的成员函数和变量的作用域规则
在类中定义的成员函数和成员变量都有自己的作用域,且根据访问修饰符不同,具体作用范围有所不同。下面进一步说明作用域的影响:
成员变量的作用域
类的成员变量的作用范围仅限于类内部,外部不能直接访问它,除非它是
public
成员或者通过public
成员函数来访问。class Test { private: int a; // 只能在类的内部访问 public: int b; // 可以在类的外部通过对象访问 // 通过成员函数访问私有成员 void setA(int value) { a = value; } int getA() { return a; } };
在上面的代码中,
a
是private
成员,不能在类外部直接访问,而b
是public
成员,可以通过对象直接访问。而a
需要通过getA()
和setA()
这样的public
成员函数间接访问。成员函数的作用域
- 成员函数在类外部可以通过对象调用,并且它们可以访问类中的所有成员,包括
private
和protected
成员。- 类的成员函数可以在类内声明,并在类外定义。它们的作用域受访问修饰符控制。
class Rectangle { private: int width, height; public: // 成员函数声明 void setValues(int, int); int area(); }; // 成员函数定义 void Rectangle::setValues(int w, int h) { width = w; height = h; } int Rectangle::area() { return width * height; } int main() { Rectangle rect; rect.setValues(5, 10); cout << "矩形的面积是: " << rect.area() << endl; return 0; }
在这个例子中,
setValues
和area
是public
成员函数,因此可以在main
函数中通过rect
对象调用它们。但width
和height
是private
的,所以不能直接访问它们,只能通过setValues
函数设置它们的值。
5.2 类作用域中的静态成员
类的静态成员变量和静态成员函数有特殊的作用域,它们属于类本身,而不是某个具体的对象。也就是说,无需实例化对象即可访问类的静态成员。
- 静态成员变量: 静态成员变量在类的所有对象中共享一份内存空间,且它们的作用域同样受访问控制修饰符的约束。
- 静态成员函数: 静态成员函数不能访问类的非静态成员(因为非静态成员是与具体对象绑定的),但它们可以访问静态成员变量。
class Test { private: static int count; // 静态成员变量 public: Test() { count++; } static int getCount() { return count; // 静态成员函数访问静态成员变量 } }; // 定义静态成员变量 int Test::count = 0; int main() { Test t1, t2, t3; cout << "对象的数量是: " << Test::getCount() << endl; // 调用静态成员函数 return 0; }
在上面的例子中,
count
是一个静态成员变量,属于类Test
,它的值在类的所有对象之间共享,并可以通过Test::getCount()
这样的静态成员函数访问。需要注意的是,静态成员变量在类的外部进行定义。
5.3 类的嵌套作用域
在C++中,类还支持嵌套类,即一个类可以定义在另一个类的作用域中。嵌套类的作用域只限于包含它的类内部。
class Outer { public: class Inner { public: void display() { cout << "这是嵌套类的成员函数" << endl; } }; }; int main() { Outer::Inner obj; // 创建嵌套类的对象 obj.display(); return 0; }
在这个例子中,
Inner
类是Outer
类的嵌套类,它的作用域仅限于Outer
类内部,外部必须通过Outer::Inner
来访问和创建对象。
6.类的实例化
类的实例化是指使用类的定义来创建具体的对象。类在C++中可以看作是一个模板,而实例化则是基于这个模板生成实际的对象,给对象分配内存并赋予它所定义的属性和行为。
6.1 什么是类的实例化
- 类是对现实世界中对象的抽象,它定义了对象的属性和方法,但并不占用内存。
- 当我们实例化一个类时,C++会根据类的定义为对象分配内存,并使对象具备类定义的功能和属性。
6.2 类实例化的语法
在C++中,实例化类的语法非常简单,使用类名作为类型,然后定义对象即可:
class MyClass { public: void display() { cout << "这是一个类的成员函数" << endl; } }; int main() { MyClass obj; // 实例化类MyClass,创建一个对象obj obj.display(); // 调用对象的成员函数 return 0; }
在上面的例子中:
MyClass
是一个类。obj
是类MyClass
的一个对象(实例)。- 我们通过
obj.display()
调用了类的成员函数。
6.3 实例化的步骤
实例化一个类通常包括以下步骤:
- 定义类:首先定义一个类,包含成员变量和成员函数。
- 声明对象:使用类的名称作为类型声明对象,就像声明基本数据类型的变量一样。每个对象都会有自己独立的成员变量,但共享同一类的成员函数。
- 调用成员函数和访问成员变量:对象被实例化后,可以通过点运算符(
.
)来访问对象的成员函数和变量(如果成员变量是公有的)。
6.4 通过构造函数初始化对象
当我们实例化类时,构造函数会被自动调用来初始化对象。C++允许通过不同的构造函数进行不同方式的初始化。
#include <iostream> using namespace std; class Rectangle { private: int width; int height; public: // 构造函数 Rectangle(int w, int h) { width = w; height = h; } int area() { return width * height; } }; int main() { // 实例化对象并调用构造函数 Rectangle rect(5, 10); cout << "矩形的面积: " << rect.area() << endl; return 0; }
在这个例子中,
Rectangle
类定义了一个构造函数Rectangle(int w, int h)
,当rect
对象被实例化时,构造函数被调用,自动将宽度和高度设置为指定的值。
6.5 动态实例化
除了静态实例化对象(直接在栈上分配内存),C++还支持使用
new
操作符在堆上动态实例化对象。这样做的好处是可以在程序运行时动态分配内存,适用于更复杂的应用场景。静态实例化:
Rectangle rect(5, 10); // 静态实例化,内存在栈上分配
动态实例化:
Rectangle* rect = new Rectangle(5, 10); // 动态实例化,内存在堆上分配 cout << "矩形的面积: " << rect->area() << endl; delete rect; // 释放动态分配的内存
在动态实例化时,使用
new
操作符分配内存,rect
是指向Rectangle
对象的指针,通过指针访问成员函数使用->
操作符。为了避免内存泄漏,动态分配的内存必须使用delete
释放。
6.6 对象数组的实例化
C++允许创建类对象的数组,这意味着可以一次实例化多个对象。对象数组的每个元素都是该类的一个实例。
class Circle { public: double radius; Circle(double r) : radius(r) {} double area() { return 3.14 * radius * radius; } }; int main() { // 实例化对象数组 Circle circles[3] = { Circle(1.0), Circle(2.0), Circle(3.0) }; // 遍历数组并调用成员函数 for (int i = 0; i < 3; ++i) { cout << "Circle " << i + 1 << " 的面积是: " << circles[i].area() << endl; } return 0; }
在这个例子中,我们创建了一个
Circle
对象数组,数组中的每个元素都是Circle
类的实例,可以分别调用它们的成员函数。
6.7 实例化的关键要点
- 每个对象有独立的成员变量:即使多个对象是从同一个类实例化出来的,它们的成员变量是彼此独立的。每个对象在内存中有自己独立的空间来存储其成员变量的值。
- 共享成员函数:虽然每个对象有自己独立的成员变量,但所有对象共享类中的成员函数。换句话说,函数本身不被复制,而是所有对象共用一份代码,只是函数内部操作的是调用者对象的成员变量。
class Point { public: int x, y; Point(int x_val, int y_val) : x(x_val), y(y_val) {} void display() { cout << "Point(" << x << ", " << y << ")" << endl; } }; int main() { Point p1(10, 20); Point p2(30, 40); p1.display(); // 输出 Point(10, 20) p2.display(); // 输出 Point(30, 40) }
在这个例子中,
p1
和p2
是Point
类的两个不同实例,虽然它们共享同一个display
函数,但输出不同的结果,因为函数操作的是各自对象的x
和y
成员变量。
6.8 析构函数的调用
当类的对象生命周期结束时(比如在函数结束时,或调用
delete
时),系统会自动调用类的析构函数来释放对象的资源。析构函数的名字是类名前面加~
号。通常用于释放动态分配的资源。class MyClass { public: MyClass() { cout << "构造函数被调用" << endl; } ~MyClass() { cout << "析构函数被调用" << endl; } }; int main() { MyClass obj; // 实例化对象,调用构造函数 return 0; // 程序结束时,自动调用析构函数 }
总结:
- 实例化是将类作为模板,创建实际的对象(实例)。
- 静态实例化在栈上分配内存,动态实例化在堆上分配内存。
- 构造函数用于在实例化时初始化对象,析构函数在对象销毁时释放资源。
- 每个对象有自己独立的成员变量,但共享同一类的成员函数。
7.类的对象大小的计算
在C++中,计算一个类的大小主要是指其对象在内存中占用的字节数。类的大小与类中包含的数据成员、继承关系、对齐方式以及可能的填充字节(padding)等因素有关。C++中的
sizeof
运算符可以用来计算类对象的大小。
7.1 类大小的计算基础
一个类的大小由其成员变量占用的空间和潜在的填充字节组成。成员函数不影响类的大小,因为函数的代码并不会存储在每个对象的内存中。
#include <iostream> using namespace std; class MyClass { int a; double b; }; int main() { cout << "MyClass 的大小: " << sizeof(MyClass) << " 字节" << endl; return 0; }
在这个例子中,
MyClass
有两个成员变量int a
和double b
。它们分别占用4
和8
字节。理论上,这个类的大小应该是4 + 8 = 12
字节,但是由于编译器的对齐要求,实际的大小可能会是16
字节。
7.2 对齐和填充
C++中大多数系统对内存有特定的对齐要求,通常是
2
、4
或8
字节。这意味着类中的每个成员变量的地址可能需要对齐到某个字节边界上,编译器可能会在变量之间插入“填充字节”以满足对齐要求。#include <iostream> using namespace std; class MyClass { char c; int a; }; int main() { cout << "MyClass 的大小: " << sizeof(MyClass) << " 字节" << endl; return 0; }
在这个例子中,
MyClass
有一个char
类型成员c
和一个int
类型成员a
。
char
通常占用1
字节。int
通常占用4
字节,但它可能需要对齐到4
字节边界。因此,在
char c
后,编译器可能会插入3
个填充字节,使int a
从4
字节对齐开始。这意味着类的实际大小可能是8
字节而不是5
字节。
7.3 空类的大小
在C++中,即使类中没有任何成员变量,空类的大小也不是
0
。为了确保每个对象都有唯一的地址,C++规定空类的大小为1
字节。class EmptyClass {}; int main() { cout << "EmptyClass 的大小: " << sizeof(EmptyClass) << " 字节" << endl; return 0; }
结果通常会输出
1
字节。
7.4 继承对类大小的影响
在继承中,基类和派生类的大小通常会加在一起。如果基类有非静态成员,派生类会继承这些成员,派生类的大小等于基类的大小加上派生类新增的成员变量的大小。
class Base { int a; }; class Derived : public Base { double b; }; int main() { cout << "Base 的大小: " << sizeof(Base) << " 字节" << endl; cout << "Derived 的大小: " << sizeof(Derived) << " 字节" << endl; return 0; }
在这个例子中:
Base
类包含一个int
,其大小通常为4
字节(考虑到对齐,实际可能是4
或8
字节)。Derived
类继承了Base
的int a
,并添加了一个double b
,double
通常占用8
字节。最终的Derived
类大小应该是Base
的大小加上double b
的大小。
7.5 虚函数对类大小的影响
类中的虚函数会增加类的大小。虚函数的存在会使类包含一个虚函数表指针(
vptr
),它指向该类的虚函数表。vptr
的大小通常是一个指针的大小,通常为4
字节(在 32 位系统中)或8
字节(在 64 位系统中)。class MyClass { int a; virtual void func() {} }; int main() { cout << "MyClass 的大小: " << sizeof(MyClass) << " 字节" << endl; return 0; }
在这个例子中,尽管
func
函数不占用对象的内存空间,但是由于存在虚函数,MyClass
的对象会包含一个虚函数表指针(vptr
)。因此类的大小会比没有虚函数时稍大一些。
7.6 静态成员对类大小的影响
静态成员变量不属于某个具体的对象,它属于整个类,因此它不会影响类的大小。静态成员变量在类外部定义并且存储在静态区,所有对象共享这一个变量。
class MyClass { static int a; double b; }; int MyClass::a = 10; // 静态成员在类外部定义 int main() { cout << "MyClass 的大小: " << sizeof(MyClass) << " 字节" << endl; return 0; }
尽管
MyClass
有一个静态成员a
,但它不会影响类对象的大小,只会计算非静态成员b
的大小。
7.7 计算类大小时的注意事项
- 虚函数表指针:当类包含虚函数时,虚函数表指针(
vptr
)会增加类的大小。- 对齐和填充:编译器会根据系统架构进行对齐,可能会插入填充字节,使类的实际大小比预期的成员变量大小要大。
- 静态成员:静态成员变量不影响类的对象大小。
7.8 实际示例:复杂类的大小计算
#include <iostream> using namespace std; class Base { int x; }; class Derived : public Base { char c; double d; static int s; }; int Derived::s = 0; int main() { cout << "Base 的大小: " << sizeof(Base) << " 字节" << endl; cout << "Derived 的大小: " << sizeof(Derived) << " 字节" << endl; return 0; }
在这个例子中:
Base
类只有一个int
,其大小通常为4
字节,但可能会有填充字节。Derived
类继承了Base
的int x
,并添加了一个char
和一个double
。由于对齐规则,类的实际大小可能比简单地将各成员的大小相加要大。
总结:
- 类的大小由其成员变量的大小和编译器对齐要求决定,成员函数不影响类的大小。
- 虚函数表指针(
vptr
)和对齐填充会影响类的大小。- 静态成员变量不影响类对象的大小。
- 内存对齐的目的是为了提高程序运行效率、减少CPU访问内存的时间,并确保硬件架构的兼容性
- 通过
sizeof
运算符,你可以准确地知道类在内存中占用的大小。
8.类成员函数的this指针
在C++中,类成员函数会隐式地接收一个指向当前对象的指针,称为
this
指针。this
指针用于访问当前对象的成员变量和成员函数。以下是this
指针的关键点和用法:
8.1. this
指针的定义
- 隐式参数:每个类的非静态成员函数都有一个隐式参数
this
,它是一个指向当前对象的指针。- 类型:对于非
const
成员函数,this
的类型是ClassName*
,即指向类实例的指针。- 只读性:
this
指针本身是只读的,无法修改this
指针使其指向其他对象。
8.2 this
指针的使用场景
- 访问成员变量:
this
指针用于区分成员变量和局部变量或参数名称相同的情况。- 返回对象的引用:可以使用
this
指针在成员函数中返回当前对象的引用,以实现链式调用(如在操作符重载时)。- 在构造函数或成员函数中传递当前对象:
this
指针可用于在类的成员函数内部将当前对象作为参数传递给其他函数。
8.3 this
指针的例子
class MyClass { public: int value; MyClass(int value) { // 使用 this 指针来区分参数和成员变量 this->value = value; } // 返回当前对象的引用(实现链式调用) MyClass& setValue(int value) { this->value = value; return *this; // 返回当前对象的引用 } // 打印成员变量的值 void printValue() const { std::cout << "Value: " << this->value << std::endl; } };
说明:
- 在构造函数中,
this->value
用于区分成员变量value
与构造函数参数value
。setValue
函数返回*this
,即返回当前对象的引用,用于链式调用。printValue
函数中使用this
访问成员变量value
。
8.4 const
成员函数中的this
指针
- 对于
const
成员函数,this
指针的类型是const ClassName*
,即指向const
类型对象的指针。意味着在const
成员函数中,无法通过this
修改当前对象的成员变量。例如:
class MyClass { public: int value; // const成员函数,不能修改成员变量 void printValue() const { std::cout << this->value << std::endl; // this->value = 10; // 错误:无法在const成员函数中修改成员变量 } };
8.5 静态成员函数中没有this
指针
- 静态成员函数:静态成员函数属于类本身,而不是某个具体的对象。因此,静态成员函数没有
this
指针,不能访问类的非静态成员。例如:
class MyClass { public: static void staticFunction() { // std::cout << this->value; // 错误:this指针不存在,不能访问非静态成员 } };
8.6 this
指针在运算符重载中的作用
在运算符重载中,
this
指针通常用于返回当前对象的引用,以支持链式操作。例如,在重载赋值运算符时,可以返回*this
来支持连续赋值:class MyClass { public: int value; // 重载赋值运算符 MyClass& operator=(const MyClass& other) { if (this != &other) { // 避免自我赋值 this->value = other.value; } return *this; // 返回当前对象的引用 }
总结:
this
指针是指向当前对象的隐式指针,用于在成员函数中访问和操作对象的成员变量。它在区分局部变量和成员变量、实现链式调用以及避免自我赋值中起到了重要作用。对于const
成员函数,this
指针为const
指针,不能修改对象状态,而静态成员函数没有this
指针。
8.7【面试题】
- this指针存在哪里?
this
指针是一个隐式参数,传递给每个非静态成员函数。this
指针的存储位置与当前的函数调用栈和运行时有关,它通常会存储在寄存器或栈中,具体取决于编译器实现和CPU架构。
- this指针可以为空吗?
this
指针不能为空,但它可以指向空对象。根据C++标准,this
指针在成员函数中总是指向当前对象。如果this
指针为空(即nullptr
),在访问对象的成员时会导致未定义行为,通常会导致程序崩溃或异常。例题:
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 (C) class A { public: void Print() { cout << "Print()" << endl; } private: int _a; }; int main() { A* p = nullptr; p->Print(); return 0; }
- 类的成员函数不直接访问对象数据:
在这个例子中,
Print()
是一个普通的成员函数,但它没有访问类的成员变量(即_a
),也没有依赖于当前对象的具体数据。因此,尽管p
是nullptr
,调用p->Print()
并不会试图访问无效的对象内存。成员函数的
this
指针在调用时是隐式传递的,但由于Print()
函数中没有使用与对象相关的成员变量,所以this
指针并未实际被解引用。
- 编译器的处理:
编译器允许通过空指针调用成员函数,只要成员函数不访问或操作与对象相关的成员变量,程序可以正常执行。
// 2.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 (B) class A { public: void PrintA() { cout << _a << endl; } private: int _a; }; int main() { A* p = nullptr; p->PrintA(); return 0; }
- 行为依赖于访问成员变量:
Print()
函数中有对成员变量_a的访问,那么this
指针将被解引用,导致程序崩溃或产生未定义行为。