C++从零开始系列篇(三):C++类和对象(上)——类的定义,类的实例化,类的大小,this指针,面试题分析

🧑‍💻博主名称:鱼子星_

✅数据结构专栏:【数据结构】

✅算法竞赛专栏:【算法竞赛】

✅C++系列专栏:【C++从零开始系列】


📖 本文导读 :本文系统讲解C++类的核心概念,涵盖类的定义与访问限定符实例化过程内存大小计算 以及this指针原理,最后通过面试题巩固理解。适合有一定C++基础的读者快速掌握面向对象编程的核心机制。那么, 让我们开始这段C++面向对象编程的探索之旅吧!


一. 类的概念与定义

类(class)是 C++ 作为面向对象编程语言的核心特性之一,其本质是一种自定义类型。与普通类型不同的是,类中不仅可以定义成员变量,还可以定义成员函数,从而将数据与操作数据的方法封装在一起。

📚 类中定义的变量称为成员变量,定义的函数称为成员函数

1. 类的定义

类需要使用关键字class来定义,紧跟在 class 之后的就是类的名称。类定义成员需要使用 {}; 括起来。类的成员函数可以直接使用类的成员变量。

cpp 复制代码
#include<iostream>
using namespace std;
//定义Stack类
class Stack
{
private:
	void Init(int n = 4)
	{
		int* tmp = (int*)malloc(sizeof(int) * n);
		if(tmp == nullptr) return;
		_a = tmp;
		_capacity = n;
		_top = 0;
	}
public:
	int* _arr;
	int _capacity;
	int _top;
};
int main()
{
	Stack st;
	return 0;
}

特别的:

  1. 由于类的成员函数可以使用成员变量,为了区分成员变量,一般会在成员变量前加上一个 _ 或者 m_(m 代表 member)
  2. C++中对结构体进行了优化,结构体类型的定义和使用与类类型大同小异。比较明显的改变为:结构体的表示不需要一直带着关键字struct,具有访问限定符,结构体内部可以定义成员函数。
  3. 定义在类中的成员函数默认为内联函数(inline)

那么如何让成员函数不成为inline函数呢?很简单,只需将成员函数的定义与声明分开即可。不过需要注意,因为类也是一个域,所以当函数不在类的内部定义时还需要添加域的声明。

Stack.h

cpp 复制代码
//定义Stack类
class Stack
{
private:
	void Init(int n = 4);
public:
	int* _arr;
	int _capacity;
	int _top;
};

Stack.cpp

cpp 复制代码
#include"Stack.h"
void Stack::Init(int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if(tmp == nullptr) return;
	_a = tmp;
	_capacity = n;
	_top = 0;
}

2. 访问限定符

C++在类的定义中引入了3个特殊的关键字:private,protected,public它们称为访问限定符。不同的访问限定符有不同的含义和效果:

  • 📘private:private 英文译为私人,机密。在类中被 private 修饰的成员具有机密性,不可被外界访问。一般情况下会将类的成员变量设置称为私有的。
  • 📖 public:public 英文译为公共的。在类中被 public 修饰的成员具有公共性,外界可以访问。一般情况下会将成员函数设置为公共的。
  • 🏷️ protected:protected在之后的篇章会讲解,它会在讲解类的继承的特性时详细说明。

访问限定符的作用范围 :当前访问限定符开始到下一个访问限定符,如果是最后一个访问限定符其作用范围到 };,在类中对于没有访问限定符修饰的成员默认被 private 修饰。

3. 类域

作用域在程序中控制着变量的调用逻辑和生命周期,在C++中一共有4个作用域:局部域,全局域,命名空间域类域。局部域和全局域控制着变量的调用逻辑和生命周期,命名空间域和类域只控制变量的调用逻辑,不控制生命周期。

3.1 类域的作用

类域的作用和命名空间相似,主要是为了防止不同的类的成员发生命名冲突。从类的层面上来讲,类域可以防止不同的类的成员发生名字冲突而命名空间域可以用来防止类的名字冲突。

📚 类的成员通过 类名 + . + 成员名字 来访问

cpp 复制代码
#include<iostream>
using namespace std;
//在不同类域中相同名字的成员不会发生冲突
class Stack
{
public:
	int* _a;
	int _capacity;
	int _top;
};
class List
{
public:
	int* _a;
	int _capacity;
	int _top;
}
namespace test01
{
	class Stack{};
}
namespace test02
{
	class Stack{};
}
int main()
{
	//类域的作用
	Stack st;
	List LT;
	st._a = nullptr;
	LT._a = nullptr;
	//命名空间的作用
	test01::Stack st1;
	test02::Stack st2;
	return 0;
}

二. 类的实例化

在类中可以定义很多的成员,但是编译器并没有为这些成员开辟空间来存储。这是由于类中定义的成员仅仅只是定义,这些定义可以用于任意一个该类型的变量,提前给这些成员开辟空间那如果没有使用这个类的话这些空间就算是空间浪费了。

当定义了一个该类的对象时这个对象的成员才会被开辟空间,而这个定义也称为类的实例化。以下使用一个日期类演示:

cpp 复制代码
#include<iostream>
using namespace std;
class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1; //类的实例化,此时d1的成员有了空间
	return 0;
}

🔹当类被实例化之后它的成员就有了存储空间,那么它的存储空间的大小是多少呢?接下来就来讨论类的大小。

三. 类的大小

前置知识:【结构体的内存对齐】

1. 类的大小的计算

类和结构体一样可以看成自定义类型,其中定义了若干成员变量,不同的是类中还定义了成员函数。我们不妨推断类的大小的计算方式为:成员变量按照内存对齐的规则计算,将成员函数当成一个函数指针,也遵循内存对齐

🔍 以下使用两个成员变量相同但是成员函数不相同的类来验证推断:

cpp 复制代码
#include<iostream>
using namespace std;
//有成员函数
class Date1
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
//无成员函数
class Date2
{
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	cout << sizeof(Date1) << endl;   //结果为:12
	cout << sizeof(Date2) << endl;   //结果为:12
	return 0;
}

经过验证发现,一个类的大小和他是否有成员函数无关,成员函数的存储空间不在类中,所以类的大小的计算就是根据成员变量按照内存对齐的方式来计算。

2. 成员函数空间的特性

🔍 为什么成员函数的存储空间不在类中?成员函数的存储空间去哪里了?

  1. 这是因为一个类可以被多个对象实例化,不同的对象它们的成员变量都是不相同的,所以需要开辟空间,但是无论该类的对象有多少个,它们的成员函数都是相同的,所以并没有必要为每一个类对象的成员函数分配空间,而是将成员函数的内存独立出来。
  2. 成员函数存储地方和普通的函数相同,可以通过 vs 调试的反汇编来观看。可以发现成员函数d1.Init的存储的地址和普通函数test在同一个地方。

四. this指针

由上述可知,一个类的不同的对象调用相同的成员函数本质上都是一个函数,那么当调用成员函数时,编译器是如何区分需要使用哪个对象来执行成员函数呢?使用隐式的this指针。

  • this 指针由编译器负责传递,其存储的是当前类类型对象的地址。每个成员函数都有一个隐式的 this 指针作为成员函数的第一个参数,所以成员函数 Init 可以看成如下形式:Init(Date* this, int year, int month, int day)

  • 由于 this 指针是隐式调用的,所以它不可以在调用的时候显式,但是在成员函数的内部可以显式的使用 this 指针,使用逻辑和普通的指针相同。

  • 调试时,进入成员函数中也可以监视 this 指针和其指向的对象的成员

cpp 复制代码
#include<iostream>
using namespace std;
//有成员函数
class Date
{
public:
	//Init(Date* this, int year, int month, int day)
	void Init(int year, int month, int day)
	{
		_year = year;
		this->_year = year;
		_month = month;
		this->_month = month;
		_day = day;
		this->_day = day;
	}
	void print()  //print(Date* this)
	{
		cout << this << endl;
		cout << sizeof(this) << endl;
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	d1.Init(1, 1, 1); //d1.Init(&d1, 1, 1, 1);
	d1.print(); //d1.print(&d1);
	return 0;
}

📚 this 指针的调用逻辑由编译器实现,在成员函数之外的地方使用 this 指针编译器会报错

五. 面试题分析

  1. 请问如下程序运行后会发生什么?( )
    1️⃣ 编译报错
    2️⃣ 程序运行崩溃
    3️⃣ 程序正常运行
cpp 复制代码
#include<iostream>
using namespace std;
class Date
{
public:
	void Init()
	{
		cout << "Init()" << endl;
	} 
	void print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date* p = nullptr;
	p->Init();
	return 0;
}

🚩 答案为:3️⃣

🔍 解析

或许很多人都会很懵,这不明显的空指针解引用吗?首先排除的应该就是 3 吧......不急,这里的刚好运用到了上面新学习的知识。首先看最具误导性的两句

cpp 复制代码
Date* p = nullptr;
p->Init();

首先 p 是 Date* 类型的指针,可以通过解引用访问 Date 类的成员函数没有问题,但是这里真的涉及空指针的解引用吗?上文说了,类的成员函数的内存不在类中,它内存的放置和普通的函数一样,所以这里的 Init 函数其实并不算是通过 p 的解引用访问到类的成员函数才得到的。

p 在调用 Init 的过程中只能算是一个引子,让编译器知道需要调用的是 Date 类中的成员函数,但是真正的调用并不需要通过 p的解引用。

📚 编译器在执行语句时并不是按部就班的判断语句是否正确,再执行语句。而是根据这个语句的逻辑来执行操作,就调用逻辑来看,上面的程序并没有什么问题。

  1. 请问如下程序运行后会发生什么?( )
    1️⃣ 编译报错
    2️⃣ 程序运行崩溃
    3️⃣ 程序正常运行
cpp 复制代码
#include<iostream>
using namespace std;
class Date
{
public:
	void Init()
	{
		cout << "Init()" << endl;
	} 
	void print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date* p = nullptr;
	p->print();
	return 0;
}

🚩 答案为:2️⃣

🔍 解析

这里和上面唯一不同点就在于调用的函数不同,由于调用成员函数编译器会隐式的传递 this 指针当使用 p 来调用成员函数时,this 指针的值就是 p 这个空指针,即 this 为空

而 print 函数内部有使用 this 指针的解引用,所以程序就是对空指针解引用了,程序崩溃

📚 使用类的对象调用成员函数,this 指针保存的是该对象的地址。使用指向对象的指针调用成员函数,this 指针就是该指针的拷贝。


本篇完结

下期预告:

  • C++从零开始系列篇(四):C++类和对象(中)------六大默认成员函数:构造函数,析构函数,拷贝构造函数,赋值运算符重载重载,取地址运算符重载