目录
0.前言
1.面向过程&面向对象
1.1面向过程编程(PP)
1.2面向对象编程(OOP)
1.3从C到C++
2.类的引入
2.1C语言中的结构体
2.2C++中类的引入
2.3结构体与类的区别
2.4为什么引入类
3.类的定义
3.1声明与定义不分离
3.2声明与定义分离
3.3如何选择
4.类的访问限定符与封装
4.1访问限定符
4.2封装
5.类的作用域
6.类的实例化
6.1概念
6.2实例化方式
7.类成员的储存
7.1计算类对象的大小
7.2类对象的储存方式
7.2.1成员变量的储存
7.2.2成员函数的储存
7.3类/结构体内存对齐
7.3.1内存对齐的基本规则
8.this指针
8.1概述
8.2为什么引入this指针
8.3this指针的特性
9.小结
(图像由AI生成)
0.前言
在计算机编程的世界里,语言的演进是为了更好地适应开发的需要,提高软件的质量和开发效率。C++作为一种高效的编程语言,它在C语言的基础上增加了面向对象的特性。这篇博客将带你了解C++中类和对象的基础知识,为你打开面向对象编程的大门。
1.面向过程&面向对象
当我们从学习C语言转向学习C++时,我们实际上是在从面向过程编程(Procedural Programming, PP)迈向面向对象编程(Object-Oriented Programming, OOP)。这两种编程范式在处理复杂性、代码组织和重用方面有着根本的不同。
1.1面向过程编程(PP)
面向过程编程是一种以过程(函数)为中心的编程范式,强调的是“做什么”和“如何做”。在C语言中,程序被视为一系列的函数调用。数据和函数是分开的,数据定义在函数外部,而函数则操作这些数据。这种方式在处理简单任务时非常有效,因为它允许程序员以线性方式思考问题。
- 优点:对于小型程序和简单问题,面向过程的方法可以快速地提供解决方案,因为它允许直接操作数据和使用简单的逻辑。
- 缺点:随着程序的增长,面向过程的代码可能变得难以维护和扩展。全局数据的使用可能导致数据被错误地修改,而代码的重用也变得更加困难。
1.2面向对象编程(OOP)
面向对象编程则是一种以对象为中心的编程范式,强调“是什么”和“能做什么”。在C++中,对象是数据和操作这些数据的函数(称为方法)的封装体。OOP通过封装(Encapsulation)、继承(Inheritance)和多态性(Polymorphism)来增加代码的重用性、灵活性和可维护性。
-
封装:封装是将数据(属性)和操作数据的代码(方法)捆绑在一起的过程,这样可以隐藏对象的实际实现细节,仅通过定义好的接口与对象交互。
-
继承:继承允许我们定义一个基类(父类)的属性和方法,然后通过派生更具体的子类来扩展或修改这些功能。这促进了代码的重用和扩展性。
-
多态性:多态性是指允许我们用一个统一的接口来操作不同类型的对象,具体操作依赖于对象的实际类型。这使得我们可以编写更通用和灵活的代码。
-
优点:OOP使得程序更易于理解、维护和扩展。通过对象的封装,可以更好地管理和保护数据。继承和多态性进一步提高了代码的重用性和灵活性。
-
缺点:面向对象的设计和实现通常比面向过程更复杂,可能需要更多的时间来学习和掌握。对于一些简单的问题,使用OOP可能会导致过度设计。
1.3从C到C++
当我们从C语言转向C++学习时,实际上是在学习如何以一种更抽象的方式思考问题。我们需要开始考虑如何将问题域内的概念建模为对象,这些对象如何相互交互,以及如何通过继承和多态性来组织和简化代码。尽管这种转变最初可能会挑战我们的思维习惯,但随着时间的推移,我们将发现面向对象的方法能够更自然地映射复杂的问题和现实世界的结构。
2.类的引入
在理解C++类的引入之前,我们首先要看看C语言中的结构体(struct
),因为它为类的概念奠定了基础。
2.1C语言中的结构体
C语言允许我们通过结构体来定义和组织不同类型的数据。结构体是一种复合数据类型,它使得我们能够将多个不同类型的变量组合成一个单一的单位。例如,如果我们想要存储一个人的信息,包括名字、年龄和身高,我们可以这样定义一个结构体:
struct Person {
char name[50];
int age;
float height;
};
结构体帮助我们在C语言中实现了数据的初步“封装”,但它的功能还是相对有限。结构体主要用于数据的存储,而对于数据的操作,则仍然依赖于外部的函数。
2.2C++中类的引入
C++在结构体的基础上引入了类(class
),这是面向对象编程的核心。类不仅包括数据成员(即属性),还包括成员函数(即方法),这使得数据和操作数据的逻辑能够被封装在一起。这种封装性是OOP的一个重要特性,它提高了代码的复用性和可维护性。
使用C++类,我们可以这样重写上面的例子:
class Person {
public:
char name[50];
int age;
float height;
void printInfo() {
std::cout << "Name: " << name << ", Age: " << age << ", Height: " << height << std::endl;
}
};
在这个类的定义中,printInfo
是一个成员函数,它与结构体内部的数据紧密相关,可以直接访问和操作这些数据。与C语言的结构体相比,C++的类提供了更高级的数据抽象和封装能力。
2.3结构体与类的区别
在C++中,结构体和类非常相似,事实上,它们之间的主要区别在于默认的访问权限:类的成员默认是private
的,而结构体的成员默认是public
的。这意味着,除非显式指定,否则类的数据成员和成员函数在类的外部是不可访问的,这强化了封装性。
2.4为什么引入类
引入类的目的是为了更好地支持抽象和封装,这是面向对象编程的核心概念之一。通过将数据和操作数据的逻辑捆绑在一起,类使得开发者能够创建更加复杂和高级的数据结构,这些数据结构不仅能够存储数据,还能够定义与数据相关的操作。这种方式大大提高了代码的重用性和可维护性,是从结构化编程向面向对象编程转变的一个重要步骤。
3.类的定义
在C++中,定义类是建立对象模板的基础步骤,涉及到成员变量(属性)和成员函数(方法)的声明及定义。C++提供两种主流的类定义方法:声明与定义不分离与声明与定义分离。
3.1声明与定义不分离
这种方法将类的声明和定义放置在同一位置,通常适用于简单的类定义。在这个方法中,类的成员函数直接在类定义内部实现。例如:
class Box {
public:
double length; // 长度
double width; // 宽度
double height; // 高度
double getVolume() {
return length * width * height; // 计算体积
}
};
这里,Box
类直接在其声明中定义了getVolume
函数。
3.2声明与定义分离
对于更复杂的类,通常采用声明与定义分离的方式。这种方法将类的声明(包含成员变量和成员函数原型)放在头文件中,而将成员函数的具体实现放在源文件中。这样做的好处包括提高代码的可维护性和编译效率。
- 头文件(Box.h) 和 源文件(Box.cpp) 示例:
// Box.h
#ifndef BOX_H
#define BOX_H
class Box {
public:
double length;
double width;
double height;
double getVolume(); // 成员函数声明
};
#endif
// Box.cpp
#include "Box.h"
double Box::getVolume() {
return length * width * height; // 成员函数定义
}
在头文件Box.h
中,我们声明了Box
类和getVolume
函数的原型。在源文件Box.cpp
中,我们定义了getVolume
函数的具体实现。
3.3如何选择
- 简单类或模板类:倾向于使用声明与定义不分离的方法,因为这简化了代码结构,减少了文件数量。
- 复杂类或大型项目:推荐声明与定义分离的方法。这种方式不仅能提高编译效率(只有在类实现改变时才需要重新编译源文件),还有助于隐藏实现细节,提升代码的模块化和可维护性。
4.类的访问限定符与封装
在C++中,类的封装是通过访问限定符来实现的,它们定义了类成员的访问范围和权限。封装不仅能保护对象的状态不被外部随意访问,还能通过定义良好的接口与外界交互,是面向对象编程中的一个核心概念。
4.1访问限定符
C++中主要有三种访问限定符:public
、private
和protected
,它们各自的含义如下:
- public:公有成员在任何地方都能被访问。
- private:私有成员只能被其所在类的成员函数访问。
- protected:受保护成员可以被其所在类以及所有子类的成员函数访问。
使用这些访问限定符可以精确控制类成员的访问权限,防止外部代码直接访问内部状态或执行不应该被外部调用的操作。
class Box {
private:
double width; // 宽度,私有成员
public:
double length; // 长度,公有成员
void setWidth(double wid); // 公有成员函数
double getWidth(void); // 公有成员函数
};
void Box::setWidth(double wid) {
width = wid; // 私有成员,只能在类内部访问
}
double Box::getWidth(void) {
return width; // 私有成员,只能在类内部访问
}
在这个例子中,width
是一个私有成员,它只能通过类的公有成员函数setWidth
和getWidth
来访问和修改。这就是封装的体现:通过公有接口暴露必要的操作,而将实现细节隐藏起来。
4.2封装
封装是面向对象编程中用于限制对对象成员的直接访问的一种机制。它有以下几个重要作用:
- 保护数据:通过将数据成员设置为私有,可以防止外部代码直接修改对象的内部状态,从而避免数据的不一致或损坏。
- 简化接口:通过公有成员函数提供操作数据的方法,可以简化对象的使用,使外部代码不需要了解对象内部的复杂逻辑就能使用该对象。
- 增强可维护性:封装使得对象的内部实现可以独立于外部代码变化,只要公有接口保持不变,就可以自由改变内部实现而不影响使用该对象的代码。
5.类的作用域
在C++中,类的作用域是一个重要概念,它定义了名称(比如变量名、函数名)的可见性和生命周期。理解类的作用域对于正确地编写和维护C++程序至关重要。
基本规则
- 类内作用域:定义在类内部的成员(包括数据成员和成员函数)在整个类内部都是可见的。这意味着类的任何成员函数都可以访问该类的所有成员,无论这些成员定义在函数之前还是之后。
- 类外作用域:类的成员在类外默认是不可见的,除非这些成员被声明为
public
。访问控制符(public
、private
和protected
)决定了类成员在类外的可见性。
示例代码
#include <iostream>
class MyClass {
private:
int a = 1; // 私有成员变量,只能在类内部访问
public:
int b = 2; // 公有成员变量,可以在类外部访问
void display() {
std::cout << "私有成员a的值: " << a << std::endl; // 类内部访问私有成员
std::cout << "公有成员b的值: " << b << std::endl; // 类内部访问公有成员
}
};
int main() {
MyClass obj;
// std::cout << obj.a << std::endl; // 错误:'a'是私有的
std::cout << "通过类外访问公有成员b的值: " << obj.b << std::endl; // 类外部访问公有成员
obj.display(); // 调用公有成员函数,它可以访问私有和公有成员
return 0;
}
类的作用域不仅定义了成员的可见性和访问权限,还影响着代码的组织和结构。合理利用类的作用域可以提高代码的可读性和维护性,避免命名冲突,并保护数据不被非法访问。
6.类的实例化
在C++中,类实例化是指根据类模板创建对象的过程。类本身像是一个蓝图,描述了对象的结构和行为,但直到我们创建了类的实例,即对象,这些描述才具有实际意义。
6.1概念
当我们实例化一个类时,实际上是在内存中分配了一块区域来存储该类的数据成员,并根据类定义初始化这些数据。这个过程可以通过调用类的构造函数来完成,构造函数是一种特殊的成员函数,专门用于初始化新创建的对象。
6.2实例化方式
C++提供了多种实例化类的方式,但最基本的两种是:
-
在栈上实例化:这是最简单的创建对象的方式,类似于基本数据类型的声明。例如,如果有一个
MyClass
类,我们可以简单地通过MyClass obj;
来在栈上创建一个MyClass
类型的对象obj
。这种方式创建的对象会在离开其作用域时自动被销毁。 -
在堆上实例化:通过使用
new
操作符,在堆上动态分配内存来创建对象。例如,MyClass* obj = new MyClass();
创建了一个指向MyClass
类型的新对象的指针obj
。使用这种方式创建的对象不会自动销毁,需要手动使用delete
操作符来释放内存。
简单示例代码
class MyClass {
public:
MyClass() {} // 构造函数
};
int main() {
MyClass obj; // 在栈上实例化对象
MyClass* pObj = new MyClass(); // 在堆上实例化对象
delete pObj; // 释放堆上对象的内存
}
7.类成员的储存
7.1计算类对象的大小
类对象的大小是其所有非静态成员的大小总和,但这个计算受内存对齐的规则影响。静态成员不占用类对象的存储空间,因为静态成员是被类的所有实例共享的。
示例代码:
#include <iostream>
class MyClass {
public:
char a; // 1 byte
int b; // 4 bytes
double c; // 8 bytes
};
int main() {
std::cout << "Size of MyClass: " << sizeof(MyClass) << " bytes" << std::endl;
return 0;
}
这个示例中,尽管char
, int
, double
分别占1, 4, 8字节,类的总大小可能大于13字节,这取决于编译器如何对成员b
和c
进行内存对齐。
7.2类对象的储存方式
类对象可以存储在堆或栈上。选择哪种方式取决于对象的预期使用寿命和程序设计。
- 栈上存储:创建时简单,由编译器自动管理内存。对象在其声明的作用域结束时自动销毁。
- 堆上存储:使用
new
关键字在堆上分配内存,适用于生命周期长或大小可变的对象。需要程序员手动管理内存,使用delete
释放。
7.2.1成员变量的储存
-
非静态成员变量:这些变量的存储空间直接包含在每个类对象中。也就是说,每当创建一个类的实例时,每个非静态成员变量都会在内存中占有一份独立的空间。这些成员的排列和大小可能受到内存对齐的影响,导致类的实际占用空间可能大于各成员大小的简单累加。
-
静态成员变量:静态成员变量不属于类的某个特定实例,而是由类的所有实例共享。它们的存储在所有对象之外,通常在程序的全局数据区或静态存储区。静态成员变量只有一份副本,无论创建多少个类实例。
7.2.2成员函数的储存
-
非静态成员函数:与静态成员变量不同,非静态成员函数并不存储在每个对象中。相反,所有对象共享同一段成员函数代码,而不是在每个对象中复制一份函数代码。这意味着成员函数不会增加单个对象的大小。
-
静态成员函数:与非静态成员函数类似,静态成员函数也不存储在对象中。它们属于类本身,而非类的某个实例,并且可以在没有创建类实例的情况下被调用。静态成员函数同样不增加对象的大小。
-
虚函数:当类中包含虚函数时,C++实现通常会在每个对象中添加一个指向虚函数表(vtable)的指针。虚函数表是一个包含指向类虚函数的指针的数组。这意味着含有虚函数的类的对象会比没有虚函数的类的对象大一个指针的大小。
class MyClass {
public:
int data; // 非静态成员变量
static int count; // 静态成员变量
void display() const { // 非静态成员函数
std::cout << data;
}
static void showCount() { // 静态成员函数
std::cout << count;
}
};
int MyClass::count = 0; // 静态成员变量的初始化
int main() {
MyClass obj;
std::cout << "Size of object: " << sizeof(obj) << " bytes" << std::endl; // 显示对象大小
MyClass::showCount(); // 调用静态成员函数
obj.display(); // 调用非静态成员函数
return 0;
}
在这个示例中,data
是每个对象中实际占用空间的非静态成员变量,而成员函数(无论是静态的还是非静态的)不占用对象的存储空间。静态成员变量count
存储在所有对象之外,并由所有对象共享。
7.3类/结构体内存对齐
内存对齐是优化数据存取效率的关键编程实践,特别是在处理结构体和类时。这涉及如何在内存中布局类或结构体的成员,以便符合处理器访问内存的最优方式。
7.3.1内存对齐的基本规则
-
对齐需求:一个类型的对齐需求通常由该类型的大小决定。例如,类型大小为4字节的
int
通常需要按4字节对齐。这意味着其地址必须是4的倍数。 -
结构体/类的对齐:一个结构体或类的总对齐需求通常由其最大成员的对齐需求决定。结构体或类的实际对齐方式会影响其总大小,因为可能在成员之间或末尾添加填充字节来满足对齐需求。
-
填充:为满足对齐需求,编译器可能在成员之间插入填充字节。填充确保每个成员都在其对齐需求指定的地址边界上开始。
示例:内存对齐的影响
考虑以下结构体示例,展示了如何计算大小并理解可能的内存填充。
#include <iostream>
struct Sample {
char a; // 1字节
int b; // 4字节,通常要求4字节对齐
char c; // 1字节
};
int main() {
std::cout << "Size of Sample: " << sizeof(Sample) << " bytes" << std::endl;
return 0;
}
在这个结构体中,尽管char
和int
成员的总大小是6字节(1 + 4 + 1),最终的结构体大小是12字节,具体取决于编译器和平台的内存对齐策略。这是因为:
b
要求4字节对齐,因此在a
后可能需要插入3字节的填充以确保b
从4字节边界开始。- 在
c
后可能需要额外填充以保证整个结构体的大小为最大对齐要求的倍数(在这里是4字节)。
控制内存对齐
在C++中,可以使用特定的编译器指令或属性来控制内存对齐。例如,GCC和Clang支持__attribute__((aligned(x)))
,而MSVC支持__declspec(align(x))
,用来指定变量或结构体成员的最小对齐。
struct __attribute__((aligned(8))) AlignedSample {
char a;
int b;
char c;
};
在这个例子中,AlignedSample
的每个实例都将按照至少8字节的边界对齐。这样的手动对齐可以帮助提高内存访问效率,尤其是在频繁访问数据时,但也可能导致内存使用效率降低。
8.this指针
在C++中,this
指针是一个特殊的指针,它在每个非静态成员函数中隐含地存在。这个指针指向调用该成员函数的对象的地址。理解this
指针的作用和特性对于编写面向对象的C++代码非常重要。
8.1概述
this
指针是每个类的非静态成员函数的隐含参数,由编译器自动提供。它用于指向调用成员函数的对象。由于每个对象的非静态成员函数访问的是相同的代码,this
指针提供了一种方法来解析对象特定的数据。
8.2为什么引入this指针
this
指针的引入主要是为了解决以下几个问题:
-
区分同名成员和局部变量:在成员函数内部,可能会有与类的成员变量同名的局部变量。
this
指针可以用来区分这些变量。通过this->成员名
,可以明确指出访问的是成员变量而非局部变量。 -
实现链式调用:通过在成员函数中返回
*this
,可以实现对同一个对象的连续操作。这样的方法常用于设计流畅接口(Fluent Interface)和方法链。 -
返回对象自身的引用:在某些需要返回调用对象自身的成员函数中,
this
指针使得函数能返回当前对象的引用。
8.3this指针的特性
this
指针具有以下特性:
-
只能在类的非静态成员函数中使用:静态成员函数不与特定的对象关联,因此不能使用
this
指针。 -
是一个常量指针:
this
指针本身不能被修改。它始终指向调用对象。 -
类型:在一个类
T
的成员函数中,this
指针的类型是T* const
,即一个指向T
类型的常量指针。这意味着你不能改变this
指针的指向(即this
本身是常量),但可以修改this
指向的对象的成员。 -
本质上是“成员函数”的形参:this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
-
不需要用户传递:this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。
class MyClass {
public:
int value;
// 使用this指针区分成员变量和参数
void setValue(int value) {
this->value = value; // 明确指定访问成员变量value
}
// 返回对象自身的引用实现链式调用
MyClass& setValueAndReturnSelf(int value) {
this->value = value;
return *this;
}
};
int main() {
MyClass obj;
obj.setValue(5);
obj.setValueAndReturnSelf(10).setValue(15); // 链式调用示例
}
9.小结
在本博客中,我们深入探讨了C++中类和对象的基本概念。从面向对象的引入、类的定义、访问限定符和封装,到类的实例化、成员存储、内存对齐以及this
指针的作用和特性,每一部分都是理解和运用C++面向对象编程的关键。这些基础知识不仅帮助我们更好地组织代码,提高程序的可读性和维护性,还是构建复杂系统时不可或缺的工具。C++ 类和对象(二),不见不散!