【C++】类和对象

1.面向过程和面向对象初步认识

我们知道,C语言是面向过程的,关注的就是问题解决的过程;

C是面向过程和面向对象混编,因为C兼容了C语言,而面向对象关注的不再是问题解决的过程;

而是一件事情所关联的不同对象。

在点外卖时我们会经常使用饿了么/美团,而它们对应的就是一个点餐系统,面向过程和面向对象在这个系统中关注的对象是不同的。

面向过程的语言会关注点餐、接单、以及骑手接单等功能函数的实现,而面向对象关注的是商家、用户、骑手之间的关系;用户点餐商家出餐,然后交由骑手配送。

2.类的引入

提到类,很多学过C语言的朋友就会联想到结构体,C兼容C语言——C语言中的struct在C中同样适用。

struct student
{
	char* name;
    int age;
};

C++在兼容struct的同时,将struct升级成了类。

类中既可以有成员变量,也可以有成员函数,像Stack中的Init、Push、Pop。

struct Stack
{
    //这里的Stack操作可以直接命名为Init、Push、Pop,因为成员函数在类内
	void Init()
	{
		a = NULL;
		top = capacity = 0;
	}
	void Push(int x)
	{}
	void Pop()
	{}
	int* a;
	int top;
	int capacity;
};

定义了Stack类型,下面来定义对象:

int main()
{
    struct Stack st1; //兼容了C语言的定义方式
    Stack st2;        //Stack是一个类,类名就是类型
    return 0;
}

此外,类相比于结构体的使用还有一个便捷之处:

typedef struct ListNode_C
{
	struct ListNode_C* next; //必须要写struct,因为typedef到LTNode;之后才生效
	int val;
}LTNode;

// C++
struct ListNode_CPP
{
	ListNode_CPP* next; //不需要写struct,ListNode_CPP是一个类名,ListNode_CPP*是一个指向ListNode_CPP的指针
	int val;
};

3.类的定义格式

class className
{
	//成员变量+成员函数  
};

C 中虽然可以使用struct来定义类,但是C 用户更偏向于使用class来定义类。

4.类的访问限定符和封装

4.1 访问限定符

public 公有
private 私有
protected 保护

public修饰的成员在类内和类外都能被访问,和我们在C语言中使用的struct相似。

protected和private在类外不能访问,且在继承中才能体现两者的区别;

访问限定符作用域从该访问限定符出现位置到下一个访问限定符,若再无访问限定符出现,就到结束。

需要特别注意的是class和struct有默认访问权限,class默认是私有的,struct默认是公有的:

class Stack
{
	void Init()
	{
		a = 0;
		top = capacity = 0;
	}
	void Push(int x)
	{}
	void Pop()
	{}
	int* a;
	int top;
	int capacity;
};

int main()
{
	Stack st;
	st.Init();//class默认访问权限私有,无法访问
	return 0;
}

在struct中访问权限默认是公有,可以直接访问。

4.2 类的两种定义方式

1.声明和定义全放在类中

像我们前面写的Stack类,声明和定义都在class Stack{}中完成。

成员函数在类内定义,如果符合inline的条件,编译器会将其当做内联函数处理。

这里为了方便观察,借助一下反汇编:

【C++】类和对象_第1张图片

2.声明和定义分离

声明在.h文件中,成员函数定义在.cpp文件中:

//f.h
struct QueueNode
{
	QueueNode* next;
	int val;
};

class Queue
{
public:
	void Init();
	void Push(int x);
	void Pop();
private:
	QueueNode* head;
	QueueNode* tail;
};
//f.cpp
void Queue::Init() 
{
	head = tail = nullptr;
}

void Queue::Push(int x)
{}

void Queue::Pop()
{}

在f.cpp中,注意限定类域(如果不写类域,无法找到对应的声明);inline函数的条件需要满足声明和定义不能分离,所以这种定义方式不会出现inline函数。

  • 小函数(汇编指令少),想成为inline直接在类中定义即可
  • 大函数,应该声明和定义分离
【面试题】

C++中struct和class的区别是什么?

解答:C兼容了C语言中struct定义结构体的使用;另外C中的struct还可以用来定义类,这和class定义类是一样的,区别在于struct定义的类默认访问权限是public,而class定义的类默认访问权限是private。

继承和模板参数列表位置

4.3 封装

面向对象三大特性:封装、继承、多态。

封装:将数据和操作数据的方法进行结合隐藏对象的属性和实现细节,仅仅对外公开接口来和对象交互。

封装本质上是一种管理,让用户使用起来更方便。

  • 像可供参观的兵马俑,如果不加以保护,那么可能慢慢的就没有了参观价值
  • Stack返回栈顶的元素,如果不加以封装,数据可能有误
cout << st.a[st.top] << endl;
cout << st.a[st.top-1] << endl;

【C++】类和对象_第2张图片

栈顶元素的下标是top还是top-1是不清楚的,如果直接访问数据,可能是错的,所以封装一个Top()就显得格外的重要。

int Top()
{
	return a[top - 1];//设计者来写,一定是对的
}

这样以来还能保证数据的正确性。

5.类的作用域

类定义了一个新的作用域,在类外定义成员的时候,需要使用::作用域限定符。

class Person
{
public:
 	void PrintPersonInfo();
private:
    char _name[20];
    char _gender[3];
    int _age;
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
 	cout << _name << " "<< _gender << " " << _age << endl;
}

6.类的实例化

前面我们提到,类名就是一个类型;但是类是抽象的,就像盖房子,类就相当于图纸,图纸是没有空间的。

要想让我们的类有意义,就需要实例化对象:

假设Person是一个类名,下面对其进行实例化:
int main()
{
    Person p1;//实例出的对象是需要占用空间的
    Person p2;
    return 0;
}

7.类对象模型

7.1 类对象的存储方式

以Person类为例:

class Person
{
public:
    void SetPersonInfo(char* name)
    {
        _name = name;
    }
 	void PrintPersonInfo()
    {
        cout << _name << " "<< _gender << " " << _age << endl;
    }
private:
    char _name[20];
    char _gender[3];
    int _age;
};

实际上类对象的存储模型可以用一张图来解释:

【C++】类和对象_第3张图片

各对象的成员变量存储在各自的地址空间中,成员函数则是放在了公共代码区;编译链接时根据函数名去公共代码区找到函数的地址。

int main()
{
    Person p;
    p.SetPersonInfo("kangkang");//编译链接时找到函数地址
    p.PrintPersonInfo();
}

7.2 类对象的大小

观察下面程序:

// 类中既有成员变量,又有成员函数
class A1 
{
public:
	void f1() {}
private:
	int _a;
};
// 类中仅有成员函数
class A2 
{
public:
	void f2() {}
};
// 类中什么都没有---空类
class A3
{};

int main()
{
    //编译环境为MSVC
    //sizeof(类名)得到的是该类型实体的大小——类对象的大小
	cout << sizeof(A1) << endl;//4
	cout << sizeof(A2) << endl;//1
	cout << sizeof(A3) << endl;//1
	return 0;
}

根据类对象的存储方式得知:成员函数是放在公共代码段的,并没有在类的内部,A2和A3结果之所以为1(编译器不同结果可能出现差异),只是标识对象存在。

成员变量的存储方式,遵循结构体内存对齐原则。

7.3 结构体内存对齐原则

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

【面试题】

1.为什么要进行内存对齐?

平台原因(移植原因) :不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

2.如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?

#pragma pack(8)
//#pragma pack可以修改编译器默认对齐数

不能按照任意字节对齐,修改对齐数时可能遇到:warning C4086: pragma 参数应为 “1”、“2”、“4”、“8” 或者 “16”。

3.什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景?

大端是将数据的低位存储在高地址处,小端是将数据的低位存储在低地址处。

int testPort()
{
	int a = 0x11223344;
	if (*((char*)(&a)) == 0x44)
		return 0;//小端
	else
		return 1;//大端
}

场景: 在不同类型的机器之间通过网络传送二进制数据时,机器大小端不同可能导致传输的数据出问题。

8.this指针

假设现在我们有一个日期类:

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 a;
};

int main()
{
	Date d1;
	Date d2;
	return 0;
}

在d1调用Init初始化的时候,该函数是如何知道该初始化d1,而不是初始化d2的?

编译器给每个非静态的成员函数增加了一个隐藏的this指针,让该指针指向当前对象。

注意:this指针的类型是类名 const this。*

【面试题】

this指针存在哪?

解答:栈;因为this指针是一个形参。

9.类的6个默认成员函数

一个类中,什么都没写简称为空类,但是空类中真的什么都没有吗?

并不是,类中会自动生成6个默认的成员函数。

【C++】类和对象_第4张图片

10.构造函数

现假设一个栈的实现是使用一个指针指向一片空间,其他成员变量暂不考虑;然后对栈进行操作,如果栈并没有初始化,那么指针就是一个野指针,如果不初始化就对栈进行操作,很有可能导致程序的崩溃。

既然我们可能会忘记进行初始化工作,那么有没有一种办法可以一定进行初始化呢?

C++新增了非常牛的构造函数,就是为了解决这一问题。

typedef int DataType;
class Stack
{
public:
	Stack(int capacity = 4)
	{
		cout << "Stack(int capacity = 4)" << endl;
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}

		_size = 0;
		_capacity = capacity;
	}
private:
	DataType* _array;
	int _capacity;
	int _size;
};

class MyQueue
{
private:
	Stack _st1;
	Stack _st2;
};

int main()
{
	MyQueue q;
	Stack st;
	return 0;
}

C++规定:默认构造函数对内置类型不做处理,对自定义类型会调用它的构造函数。

队列的实现可以借助两个栈,那我们写栈的构造函数,不写队列的构造函数,那么编译器生成队列的默认构造会调用栈的默认构造函数。

默认构造函数由三种:

  1. 编译器自己生成的
  1. 全缺省的构造函数
  1. 无参构造函数

注意:一个类中不写构造函数的话,编译器会自动生成;写了构造函数编译器就不会再自动生成。

C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时

可以给默认值

class Time
{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
private:
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	// 自定义类型
	Time _t;
};

像这个程序就用到了C++11中的补丁:

  • Date类没有写构造函数,编译器会自动生成
  • 自动生成的构造函数会使用Date类中给的缺省值进行初始化
  • 自定义类型调用它的构造函数

总结:一般的类都不会让编译器默认生成构造函数,都会自己写;只有特殊情况才会使用默认生成。

11.析构函数

11.1 概念

通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没的呢?

析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成

的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作

11.2 特性

  • 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数
  • 对象生命周期结束时,C++编译系统系统自动调用析构函数
class Time
{
public:
    ~Time()
    {
        cout << "~Time()" << endl;
    }
private:
    int _hour;
    int _minute;
    int _second;
};
class Date
{
private:
    int _year = 1970;
    int _month = 1;
    int _day = 1;
    Time _t;
};

Date类没有写析构函数,编译器会自动生成一个析构函数;自动生成的析构函数在遇到自定义类型时,会调用它的析构函数。

在销毁对象时内置类型不需要进行资源清理; Date类默认生成的析构函数,会调用Time类的析构函数。

总结:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;但是有

资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。

12.拷贝构造函数

12.1 概念

在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。

那在创建对象时,可否创建一个与已存在对象一模一样的新对象呢?

拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般使用const修饰)。

12.2 特征

1.拷贝构造函数是构造函数的一个重载形式

2.拷贝构造函数的参数只有一个,且必须是类对象的引用; 使用传值方式编译器直接报错,会引发无穷的递归调用。

【C++】类和对象_第5张图片

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

3.若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按字节序完成拷贝,这种拷贝叫做浅拷贝/值拷贝。

注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。

现假设有一个struct结构体Stack,它内部有一个成员变量是一个指针类型,创建两个Stack对象st1和st2。

【C++】类和对象_第6张图片

那么st1指向的是一片动态开辟的空间,而编译器默认生成的拷贝构造会进行值拷贝,也就是说st2中的a也指向这片空间,所以在Stack完成资源清理的时候会将同一片空间释放两次,导致程序崩溃。

【C++】类和对象_第7张图片

可以很明显的看出,同一片空间释放了两次,所以默认生成的拷贝构造函数还是不够的。

13.赋值运算符重载

13.1 运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类****型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名字为:关键字operator 后面接需要重载的运算符符号

函数原型:返回值类型 operator操作符(参数列表)

注意:

  • 不能通过连接其他符号来创建新的操作符:比如operator@
  • 重载操作符必须有一个类类型参数用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
  • 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
  • . :: sizeof ?: .* 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	bool operator==(const Date& d2)
	{
		return _year == d2._year
			&&_month == d2._month
			&& _day == d2._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

13.2 赋值运算符重载

赋值运算符重载的形式:

  • 参数类型:const T&,传递引用可以提高传参效率
  • 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
  • 检测是否自己给自己赋值
  • 返回*this :要复合连续赋值的含义
Date& operator=(const Date& d)
 {
     if(this != &d)
     {
         _year = d._year;
         _month = d._month;
         _day = d._day;
     }
     return *this;
 }

注意:赋值运算符只能重载成类的成员函数不能重载成全局函数。

// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
    if (&left != &right)
    {
     left._year = right._year;
     left._month = right._month;
     left._day = right._day;
    }
    return left;
}

【C++】类和对象_第8张图片

原因:赋值运算符如果不显式实现,编译器会生成一个默认的;此时用户再在类外自己实现一个全局的 赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的 成员函数。


如果我们并没有显示的去写赋值运算符重载,编译器会自动生成一个,以值的方式逐字节的拷贝;内置类型直接赋值,自定义类型会调用对应的赋值运算符来完成。

14.取地址和const取地址运算符重载

这两个默认成员函数,编译器会自动生成,一般不需要我们去写。

class Date
{
public:
	Date* operator&()
	{
		return this;
	}

	const Date* operator&()const
	{
		return this;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日
};

这两个成员函数一般默认的就可以使用,只有特殊情况才需要我们去写——比如想让别人获取指定的内容。

15.初始化列表

初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。

下面举个栗子:

class Date
{
public:
	Date(int year, int month, int day)
		: _year(year)
		, _month(month)
		, _day(day)
	{}
private:
	int _year = 0;//缺省值
	int _month;
	int _day;
};

Date类中的成员变量使用初始化列表还是在构造函数体内进行初始化都是可以的,但是有三种成员变量只能使用初始化列表来初始化:引用成员变量、const成员变量 、自定义类型成员(且该类没有默认构造函数时),否则是无法通过编译的。


15.1 引用成员变量

class Date
{
public:
	Date(int& x)
		: ref(x)
	{}
private:
	int& ref;
};

15.2 const成员变量

class Date
{
public:
	Date(int year, int num)
		: _year(year)
		, N(num)
	{}
private:
	int _year;
	const int N;
};

15.3 自定义类型成员(且该类没有默认构造函数时)

class Time
{
public:
	Time(int hour)
	{
		_hour = hour;
	}
private:
	int _hour;
};
 
class Date
{
public:
	Date(int year, int hour)
	{
		_year = year;
		Time t(hour);
		_t = t;
	}
private:
	int _year;
	Time _t;
};

16.explicit关键字

这里同样以Date类为例,并且有单参数的构造函数:

class Date
{
public:
	Date(int year)
		:_year(year)
	{
		cout << "	Date(int year)" << endl;
	}
private:
	int _year;
};

那我们创建类对象的方式可以这样写:

int main()
{
	Date d1 = 2022;
	return 0;
}

2022会发生隐式类型转换,然后生成一个Date类的临时对象,之后通过拷贝构造创建d1。

如果给Date类的单参数构造函数,加上explicit关键字,那么就无法发生隐式类型转换,像以上这种写法就会报错。

explicit Date(int year)
    :_year(year)
{
    cout << "	Date(int year)" << endl;
}

【C++】类和对象_第9张图片

17.static成员

声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰成员函数,称之为静态成员函数

注意:静态成员变量一定要在类外进行初始化。

【面试题】

实现一个类,计算程序中创建出了多少个类对象。

实现:

class A
{
public:
	A() { ++_scount; }
	A(const A& t) { ++_scount; }
	~A() { --_scount; }
	static int GetACount() { return _scount; }
private:
	static int _scount;
};
int A::_scount = 0;

静态的_scount任何类对象都可以访问,调用构造函数和拷贝构造函数的时候++_scount,析构的时候

–_scount就可以了。

需要注意的是静态成员函数没有隐藏的this指针,不能访问任何非静态成员。

下面给class A增加上成员变量nostatic,并在static成员函数中访问:

class A
{
public:
	A() { ++_scount; }
	A(const A& t) { ++_scount; }
	~A() { --_scount; }
	static int GetACount() 
	{ 
		nostatic = 0;
		return _scount; 
	}
private:
	static int _scount;
	int nostatic;
};

【C++】类和对象_第10张图片

没有this指针,这种访问是无法进行的。

C++类和对象的大部分内容在本篇中都有提到,可能还有一小部分知识点没有涉及到,没有涉及到的知识点大家可以私信我,我后续也会对内容做一些补充,尽可能抽出时间来慢慢完成。

你可能感兴趣的:(C++,c++,类和对象,面向三个特性之封装)