从零开始搞定类和对象(上)

发布于:2025-08-01 ⋅ 阅读:(12) ⋅ 点赞:(0)

类的定义——class关键字

类是 C++ 面向对象编程(OOP)的核心概念,它是数据和操作这些数据的函数的封装体。

类的定义的语法格式:

class   类名

{    

        // 类的主体

       类中的函数

       类中的变量

};

class为定义类的关键字,后面跟着类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。类体中内容称为类的成员:类中的变量称为类的属性或成员变量;类中的函数称为类的方法或者成员函数。

定义在类里面的成员函数默认为被inline修饰的内联函数,但具体是否展开取决于编译器。

代码示例:

//定义一个栈的类
class Stack
{
	//定义相关方法:成员函数
	void init()
	{ 
        //不写具体实现方法,仅为了体现类的定义
    }
	void push()
	{
        //不写具体实现方法,仅为了体现类的定义
    }
	//定义相关成员变量
	int* arr;
	int top;
	int capacity;
};

从上面我们可以感受到,C++中的类与C语言中的结构体比较相似,或者说类是结构体的升级版,因为类中除了可以包含成员变量,还可以包含相关的函数实现等。那么,我们定义了类,应当如何使用类里面的成员呢?

与C语言中使用结构体成员访问操作符"."一样,我们定义了类的对象(或者说是变量,准确来说是对象,只是我们在初期学习阶段,可以把它叫成变量)以后,可以使用“.”进行类的成员的访问,或者定义了指针以后,使用“->”对类的成员进行访问:

#include<iostream>
using namespace std;
class Stack
{
	//定义相关方法:成员函数
	void init()
	{ }
	void push()
	{ }
	//定义相关成员变量
	int* arr;
	int top;
	int capacity;
};
int  main()
{
	//利用类可以定义相应的对象
	//类名就是类型,不需要再添加关键字
	Stack s1;
	Stack s2;
	//定义了类的对象以后,就可以利用对象去访问类中的成员
	s1.arr;
	return 0;
}

与C语言中结构体变量的创建不同的是,我们创建类的对象时并不需要加上class这个关键字,直接写类名就好了。定义了对象以后,就可以访问类中的成员了,但是,如果我们直接这么写代码,在编译器上就会报错。错误信息如下:

这是为啥呢?这就与访问限定符有关了。

访问限定符

 如上所见,访问限定符包括三种:public,protected,private。

  • 为啥要有访问限定符呢?

C++ 的 访问限定符publicprivateprotected)是 面向对象编程 的核心特性之一,主要用于 控制类成员的访问权限,从而实现 封装,提高代码的 安全性、可维护性和灵活性

  • 限定符的作用

public:public修饰的成员在类外可以直接被访问使用

protected和private:由这两个限定符修饰的成员在类外不可以直接访问使用,现阶段我们姑且视他们的用法 相同,具体区别将在后面的继承章节体现

注意,访问权限的作用域从该访问限定符的出现位置开始到下一个访问限定符出现为止,如果后面没有访问限定符,作用域就直到类结束(即大括号结束)。

上面的代码之所以报错,是因为class定义的成员没有被访问限定符修饰时默认为private,所以类里面的成员只可以在类的作用域内使用,不可以在类外使用。

所以,如果类里面的成员变量需要再类外使用,那我们就要用public修饰。

代码示例:

class Stack
{
public:
	//定义相关方法:成员函数
	void init()
    { }
	void push()
	{ }
	//定义相关成员变量
private:
	int* arr;
	int top;
	int capacity;
};
int  main()
{
	//利用类可以定义相应的对象
	//类名就是类型,不需要再添加关键字
	Stack s1;
	Stack s2;
	//定义了类的对象以后,就可以利用对象去访问类中的成员
	//公有的可以被访问
	//类名可以直接作为类的类型
	s1.init();
	//私有的不可以被访问
	//s1.arr;
	return 0;
}

类的定义——struct关键字

C++中的关键字struct也可以定义类,C++兼容C语言中struct的用法,同时将struct升级为了类,相较于struct定义结构体,明显变化是struct中可以定义函数,一般情况下我们还是推荐用class定义类。

struct S
{
	int top;
	int* arr;
	int capacity;
};
struct listnode
{
	int val;
	//类名就是类的类型,所以不需要加上struct关键字
	listnode* next;
};

int main()
{
	//如果把S当做结构体:
	//此时就是定义结构体变量
	struct S s1;
		//如果把S当做类名
		//此时就可以定义类的对象
		 S s2;
	return 0;
}

可以看到,在 C++ 中定义结构体(struct)变量时,通常不需要再加 struct 关键字,直接使用结构体名字即可。这是 C++ 对 C 语言的改进,使代码更简洁。但 C++ 仍然兼容 C 的写法(加 struct 关键字)。

需要注意的是,struct关键字定义的类中的成员默认被public修饰。

类域

类定义了一个新的作用域,类的所有成员都在类的作用域中,在类体外定义成员时,需要使用::作用域操作符指明成员属于哪个类域。

class Stack
{
public:
	//定义相关方法:成员函数
	void init(int n=4);
	void push()
    { }
	//定义相关成员变量
private:
	int* arr;
	int top;
	int capacity;
};
void init(int n)
  {
      arr=(int*)malloc(sizeof(int)*n);
      top=0;
      capacity=n;
  }
int  main()
{
	Stack st;
    st.init();
	return 0;
}

类域影响的是编译的查找规则,下面程序Stack类中声明的init函数定义与声明分离时,如果在类外定义init函数而不说明类域,那么编译器就把Init当成全局函数,那么编译时,找不到arr等成员的声明/定义在哪里,就会报错。指定类域Stack,就是知道Init是成员函数,当前域找不到的arr等成员,就会到类域中去查找。

所以正确的写法:

class Stack
{
public:
	//定义相关方法:成员函数
	void init(int n=4);
	void push()
    { }
	//定义相关成员变量
private:
	int* arr;
	int top;
	int capacity;
};
void Stack::init(int n)
  {
      arr=(int*)malloc(sizeof(int)*n);
      top=0;
      capacity=n;
  }
int  main()
{
	Stack st;
    st.init();
	return 0;
}

讲完这些以后,还有一个小问题想要问一下小伙伴们,我们在定义了类以后,类里面有了相应的成员变量,那么在类中的那些成员变量是变量的声明还是变量的定义呢?

注意啦!!!在 C++ 中,类(class 或 struct)内部的成员变量属于声明(declaration),而不是定义(definition)。因为要看他是声明还是定义就要看内存是否为这个变量分配了空间,以上面的代码为例,如果我们在主函数中直接访问类中的成员变量top,并执行top++操作,如下:

class Stack
{
public:
	//定义相关方法:成员函数
	void init(int n=4);
	void push()
    { }
	//定义相关成员变量
private:
//这是成员变量的声明,不是定义
	int* arr;
	int top;
	int capacity;
};
void Stack::init(int n)
  {
      arr=(int*)malloc(sizeof(int)*n);
      top=0;
      capacity=n;
  }
int  main()
{
	Stack.top++;
	return 0;
}

如果把这个代码拷贝到编译器上,就会报错,所以内存并没有为它创建空间。 

要真正创建对象并使用这些成员变量,必须实例化类(即创建类的对象),此时才会分配内存,完成定义。

类的实例化

概念

  • 用类这一类型在物理内存中创建对象的过程,称为类实例化出对象。
  • 类是对对象进行一种抽象描述,是一个模型一样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,用类实例化出对象时,才会分配空间。
  • 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量。打个比方:类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,设计图规划了有多少个房间,房间大小功能等,但是并没有实体的建筑存在,也不能住人,用设计图修建出房子,房子才能住人。同样类就像设计图一样,不能存储数据,实例化出的对象分配物理内存存储数据。

通俗的讲,我们定义了一个类,并用类类型创建了一个变量,类里面的成员变量才有了空间,这一过程就叫做类的实例化。上面那些代码中,我们利用类创建的变量就是实例化出的对象。

 对象大小

计算类的对象大小与C语言中结构体的大小类似,牵扯到内存对齐规则。

内存对齐规则

  • 第一个成员在与结构体偏移量为0的地址处。
  • 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
  •  注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值,VS中默认的对齐数为8
  •  结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  •  如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

 我们还是借助上面的代码来分析一下对象的大小:

#include<iostream>
#include<stdlib.h>
using namespace std;
class Stack
{
public:
	//定义相关方法:成员函数
	void init(int n = 4);
	void push()
	{
	}
	//定义相关成员变量
private:
	//这是成员变量的声明,不是定义
	int* arr;
	int top;
	int capacity;
};
void Stack::init(int n)
{
	arr = (int*)malloc(sizeof(int) * n);
	top = 0;
	capacity = n;
}

int  main()
{
	Stack s1;
	cout << sizeof(s1) << endl;
	return 0;
}

我们先来只看类的对象中成员变量的总大小:结合内存对齐规则,结果为12个字节,那么对象中的函数,那么对象的对象是否还需要包含成员函数的大小?这似乎有点难分析,那我们直接来看一下结果:在VS、X86 环境下,答案是12,恰好与成员变量的总大小相同,这个结果说明,对象的那块内存中并不包含成员函数的空间。

我们来分析一下:

#include<iostream>
#include<stdlib.h>
using namespace std;
class Stack
{
public:
	//定义相关方法:成员函数
	void init(int n = 4);
	void push()
	{
	}
	//定义相关成员变量
	//这是成员变量的声明,不是定义
	int* arr;
	int top;
	int capacity;
};
void Stack::init(int n)
{
	arr = (int*)malloc(sizeof(int) * n);
	top = 0;
	capacity = n;
}

int  main()
{
	Stack s1;
	s1.top = 9;
	s1.init();
	Stack s2;
	s2.top = 2;
	s2.init(10);
	return 0;
}

问大家一个问题,对象s1中的top和对象s2中的top是否是同一个变量(或者说他们是否共用同一块内存),对象s1和s2中的init函数又是否是同一个呢?或者说,对象中是否包含成员函数呢?

首先函数被编译后是一段指令,对象中没办法存储,这些指令存储在一个单独的区域(代码段),那么对象中非要存储的话,只能是成员函数的指针,但是这里需要再额外说一下,其实函数指针是不需要存储的,函数指针是一个地址,调用函数被编译成汇编指令(call 地址),其实编译器在编译链接时,就要找到函数的地址,不是在运行时找,只有动态多态是在运行时找,就需要存储函数地址,这个我们以后会讲解。(了解即可)

另外,我们可以通过调试和反汇编(不会的伙伴也不用担心,只需要看看即可,不需要特地去掌握汇编语言哦)来说明这一点。

如图,通过调试和反汇编发现,s1和s2中的成员变量确实不是同一个变量,但是s1和s2调用的却是同一个函数。

既然每个对象的成员函数都是一样的,那如果再将同一个函数存入到不同的对象的空间中就会造成内存的浪费,所以类中的函数并不会存储在对象中。 

综上,计算类的对象的大小只需要按照结构体变量的大小去计算即可。

那现在我们再来看一下下面这个代码:

#include<iostream>
using namespace std;
class B
{
public:
	void print()
	{
		cout << "void print()" << endl;
	}
};
class C
{

};
int main()
{
	cout << sizeof(B) << endl;
	cout << sizeof(C) << endl;
}

上述代码的运行结果是啥?

有的小伙伴可能会觉得两个结果都是0,因为我们之前已经说过了,计算对象的大小时,不需要计算成员函数的大小,而这里面也没有其他的成员变量,那么,事实真的是这样吗?

运行这个代码,发现打印结果都是1.

为啥答案是1个字节呢,之前也没有说这样的问题呀,这里小编就不给大家卖关子了,这里开一个字节的空间的目的是占位,不存储实际数据,只是为了表示对象存在(被定义)过。

this指针

//日期类
#include<iostream>
using namespace std;
class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
protected:
	//这里只是声明,不是定义
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	Date d2;
	d1.Init(2025, 7, 16);
	d2.Init(2025, 7, 31);
	d1.Print();
	d2.Print();
	return 0;
}

 且看以上代码,上述代码的执行结果如下:

看到这个代码和执行结果,小伙伴们能否想到一些问题呢?

小编就不装神弄鬼了,我们可以看到,在代码中我们分别调用了两次init()函数和print()函数,并且通过执行结果发现,第一次调用init函数,对d1中的成员变量进行了初始化并在调用print函数时打印了d1中成员变量被初始化以后的值,同理第二次调用就对d2中的成员变量进行打印和初始化。这一切看起来好像理所当然,没有什么问题呀。 

但是,我们之前已经说过了,成员函数的空间是独立于对象的空间之外的,而且两次调用的init函数和print函数是相同的,那为什么每次通过对象调用函数却能精准的对对象中的相关成员变量进行操作呢,这里面究竟暗藏何玄机?

这就是编译器通过一件“神器”——隐含的this指针搞定的。

编译器在编译过程中,会把第一次调用通过d1调用的init函数进行处理:

由:

void Init ( int year , int month , int day );

编译器处理后变成:

void Init (Date*this ,  int year , int month , int day );

传递参数时,同样会进行相关处理:

由:

d1.Init (2025,7,16);

经过编译器处理变成:

d1.Init(&d1,2025,7,16);

也就是把调用对象的地址传递给形参

函数实现的内部:

由:

void Init(int year, int month, int day)
    {
        _year = year;
        _month = month;
        _day = day;
    }

经过编译器处理:

void Init(Data*this,int year, int month, int day)
    {
       this-> _year = year;
       this->  _month = month;
       this-> _day = day;
    }

为什么叫做隐含的this指针呢?因为如果你真的在代码中写成编译器转换后的形式,就会报错。

但是呢,我们可以在类中函数的定义里面加上this指针指向,也就是:

//日期类
#include<iostream>
using namespace std;
class Date
{
public:
	void Init(int year, int month, int day)
	{
		this-> _year = year;
		this-> _month = month;
		this-> _day = day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
protected:
	//这里只是声明,不是定义
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	Date d2;
	d1.Init(2025, 7, 16);
	d2.Init(2025, 7, 31);
	d1.Print();
	d2.Print();
	return 0;
}

但注意不要改变参数列表哦。

综上,有:

  • 类的成员函数中访问成员变量,本质上都是通过this指针访问的
  • C++规定不能在实参和形参的位置显式地写this指针(编译时编译器会处理),但是可以在函数体内使用this指针。

另外,其实我们上面在写this指针的类型时,其实是不太完整、不太正确的

刚才写的:Date*this

正确写法:Date* const this

这也就意味着,在函数体内部,this指针是不可以改变指向的。

this指针存储在内存中的哪个区域?

由于this指针是在传参过程中出现的,this指针作为形参,是在栈上创建的。

牛刀小试

分析:首先,我们可以完全排除选项A 。因为这道题最大的问题就出现在空指针解引用了,而编译报错检查的是语法错误,空指针解引用本身不是语法错误,而会导致运行时崩溃,那这个题难道选B?那就要看在运行时,这段程序是否通过p这个空指针解引用去访问成员函数了。

我们知道,编译完成以后,我们的代码会被转换成指令,调用函数时,我们在上面查看反汇编代码时也看到了,执行的是call指令,需要得到函数的地址,但是,重点是,函数的地址是在对象对应的那块空间内嘛?我们在讲对象大小的那一部分就讲过,函数的地址是不会放在对象里面的,而是在编译时就已经确定的,所以实际上并不会通过p这个空指针去访问print函数。所以并不会产生空指针解引用的问题。

那么又有一个问题,我们用p这个指针的目的是啥捏?目的就是传参,大家还记得刚刚讲过的this指针嘛,你在使用对象调用函数时,实际上会编译器有一个this指针作为函数的形式参数,接受的就是对象的地址,而p又是对象的指针,所以这里的实参就是p。

总结前面解答,就是:

  • 成员函数(如 Print())的调用在编译时确定,不依赖对象实例
  • 编译器会将 p->Print() 转换为 A::Print(p),而 Print 函数内部没有访问成员变量(如 _a),因此不会解引用 p

综上所述,答案选C。

那如果把上面的题目中p->Print(),改成  (*p).Print()  答案选啥呢?

改完之后的问题还是在于空指针是否解引用了的问题,分析过称与上面一样,选C。

这一题结合前一题的讨论,调用函数并不会出现空指针解引用的问题,问题就在于函数体中的语句:cout<<_a<<endl,我们前面已经说过编译时指针p的作用就是传参,会把p这个空指针当做参数传递给函数,此时this指针里面接收的就是空指针,而函数体中_a的访问就是要访问对象中的成员变量,成员变量是存储在对象中的,要通过指针this解引用访问对象才行,这就造成了空指针解引用,所以就会运行时崩溃,所以选B。
 


网站公告

今日签到

点亮在社区的每一天
去签到