从基础入门到学穿C++(类和对象篇)【超详细】【一站式速通】

2024-06-04 12:57:59 浏览数 (1)

类和对象

C语言是一种面向过程的语言,C 和python、java一样都是一种面向对象的语言。

面向对象编程(Object-Oriented Programming,OOP)和面向过程编程(Procedural Programming)是两种不同的编程范式

  • 面向对象编程:强调的是将现实世界中的事物抽象成对象,并通过对象之间的交互来实现程序逻辑。程序中的对象具有状态(属性)和行为(方法),能够封装数据和功能。代码以类(Class)和对象(Object)为单位进行组织,将相关的数据和功能封装在一起,以提高代码的可维护性和复用性。通过类的封装特性,可以隐藏对象的内部细节,只暴露必要的接口,提高了代码的安全性和灵活性。支持继承和多态的特性,可以通过继承机制实现代码的复用,通过多态机制实现不同对象对同一消息的不同响应。
  • 面向过程编程:以执行过程为中心,通过一系列的步骤来完成任务。程序由一系列的函数或过程组成,每个函数都是对一系列指令的封装。代码以函数或过程为单位进行组织,按照执行顺序编写,重点在于流程和步骤的顺序。通常不直接支持继承和多态,代码复用需要通过函数的调用来实现。

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


类的引入

类的定义
代码语言:javascript复制
class className//class关键字
{
	// 类体:由成员函数和成员变量组成
};  // 一定要注意后面的分号,跟struct一样不可省略

class为定义类的关键字,ClassName为类的名字,**{}**中为类的主体,注意类定义结束时后面分号不能省略。

类体中内容称为类的成员,类中定义的变量称为成员变量,类中定义的函数称为成员函数

类中的成员在进行命名时,一般要加上前缀修饰符或者后缀修饰符,以区别成员变量和函数形参,避免造成混淆。如下实例:

代码语言:javascript复制
class student
{
public:
    student(int age)
    {
        _age = age;
    }
private:
    int _age;
};
访问限定符

C 实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用

  • public:公有
  • protected:保护
  • private:私有

说明

1.public修饰的成员在类外面可以直接访问,protected和private修饰的成员在类外面不可以直接被访问(protected和private其实有一定的区别,后期再做区分)

2.访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止

3.类中有公有和私有的访问限定符,我们如果定义了变量或者函数却没有用访问限定符,那么它**(class类)默认是私有的**。C 兼容C的语法,也可以通过struct定义类,但是在struct中成员默认是公有的

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员,需要使用::作用域解析符知名成员属于哪个类域。

声明和定义的区别

声明是一种承诺,承诺要做某事,但是还没实施。定义就是把这个事情做了

类的实例化
概念

类就像图纸,类的实例化就像是拿着图纸去建造。

类实例化的对象中只存储成员变量,不存储成员函数

一个类实例化出N个对象,每个对象的成员变量都可以存储不同的值,但是调用的函数却是同一个。

如果每个对象都放成员函数,而这些成员函数却是一样的,浪费空间

ps:explicit关键字:用explicit修饰构造函数,将会禁止构造函数的隐式转换。

类存储数据的策略

代码只保存一份,在对象中保存存放代码的地址

只保存成员变量,成员函数存放在公共的代码段

如何计算对象的大小

计算成员变量之和,并且考虑内存对齐(结构体内存对齐知识),类似于结构体。

总结:一个类的大小,实际就是该类中“成员变量”之和,当然也要进行内存对齐,注意空类的大小,空类比较特殊,编译器给了空类一个字节的大小来唯一标识这个类

为什么空类的大小是1而不是0

编译器开一个字节不是为了存数据,而是为了占位,表示对象存在,因为该类是唯一的。

隐含的this指针

在调用函数接口的时候,编译器会默认取变量的地址作为第一个参数,在函数接口中形参默认用一个this指针进行接受。

this指针是谁调用这个成员函数,this就指向谁,也就是说编译器自动帮我们传递了一个参数。

this指针是存在哪里的(存在进程地址空间的哪个区域)

存在栈上,因为它是一个形参


封装

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

封装本质上是一种管理,既能保护数据,又能使得用户能够调用提供的功能。

比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。使用者根本不用关心电脑的运行原理,只要能使用相关的功能,达成电脑和人的交互即可。

代码语言:javascript复制
typedef int DataType;
class Stack
{
public:
    void Init()
    {
        _array = (DataType*)malloc(sizeof(DataType) * 3);
        if (NULL == _array)
        {
            perror("malloc申请空间失败!!!");
            return;
        }
        _capacity = 3;
        _size = 0;
    }

    void Push(DataType data)
    {
        CheckCapacity();
        _array[_size] = data;
        _size  ;
    }

    void Pop()
    {
        if (Empty())
            return;
        _size--;
    }

    DataType Top()
    {
        return _array[_size - 1];
    }

    int Empty()
    {
        return 0 == _size;
    }

    int Size()
    {
        return _size;
    }

    void Destroy()
    {
        if (_array)
        {
            free(_array);
            _array = NULL;
            _capacity = 0;
            _size = 0;
        }
    }

private:
    void CheckCapacity()
    {
        if (_size == _capacity)
        {
            int newcapacity = _capacity * 2;
            DataType* temp = (DataType*)realloc(_array, newcapacity * sizeof(DataType));
            if (temp == NULL)
            {
                perror("realloc申请空间失败!!!");
                return;
            }
            _array = temp;
            _capacity = newcapacity;
        }
    }

private:
    DataType* _array;
    int _capacity;
    int _size;
};

这是一份C 版本的stack代码,我们可以明显的发现与C语言版本不同的地方。使用C语言版本实现的stack,结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的。而在C 版本的代码中可以通过类将数据和操作方法进行结合,通过访问限定符控制成员函数被调用的权限,实现封装性。


类的6个默认构造函数

如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。

初始化和清理

构造函数、析构函数

拷贝复制

拷贝构造、赋值操作符重载

取地址重载(一般这两个我们自己不实现)

对于普通对象取地址和对于const对象取地址

构造函数

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次

调用特点

1.针对内置类型的成员变量没有做处理

2.针对自定义类型的成员变量,调用它的构造函数初始化

3.一旦用户显式定义编辑器将不再生成–>如果有一个传参的函数,一个没有传参的函数,那么就要定义两个构造函数,比较繁琐,其实我们可以采用全缺省的方式来解决该问题(全缺省)

代码语言:javascript复制
Date(int year = 0 , int month  = 1  , int day  = 1)

全缺省和无参构造函数不能同时存在,因为此时会造成冲突,使用两者中的任何一个都可以,编译器不知道该识别哪一个。

代码语言:javascript复制
Date()
{
	_year = 0;
	_month = 1;
	_day = 1;
}
Date(int year = 0, int month = 1, int day = 0)
{
	_year = year;
	_month = month;
	_day = day;
}

我们如果同时写出了这两个构造函数,编译器会报错:对重载函数的调用不明确。

无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。

注意:默认的构造函数:不传参就可以进行调用。无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。

析构函数

析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。有了析构函数,我们在堆上开辟空间后就不用再一次次的free or delete了,这又提高了我们的效率。

析构函数的特征:

  • 析构函数名是在类名前加上字符 ~。
  • 无参数无返回值类型。
  • 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
  • 对象的生命周期到了以后,自动调用,完成对象里面的资源清理工作
代码语言:javascript复制
~Date()
{
    //释放资源
}

如果类中没有在堆区申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。

拷贝构造

我们在实际应用中可能有这样一个场景:我们要利用一个对象去创建另外一个对象,这就需要拷贝构造的方法。

拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

拷贝构造的特征:

  1. 拷贝构造函数是构造函数的一个重载形式
  2. 拷贝构造函数的参数只有一个必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
代码语言:javascript复制
//这两个写法都是调用拷贝构造
Date d2(d1);
Date d2 = d1;

无穷递归调用的实例:

代码语言:javascript复制
//Date d2(d1)该调用是想要用d1去创建d2
Date(Date d)
{
	_year = d._year;
    _month = d._month;
    _day = d._day;
}

拷贝构造调用函数时要先传参,d1是一个自定义类型,要拿d1去构造d,这里又会发生拷贝构造,最终语义上形成无穷递归。(简单来说就是传参的过程又是一个拷贝构造的调用)编译器会直接检查出来这种错误。

在这里插入图片描述在这里插入图片描述
在这里插入图片描述在这里插入图片描述
代码语言:javascript复制
class Date
{
public:
	Date(int year = 0, 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;
		cout << "copy creat" << endl;
	}
private:
	int _year;
	int _month;
	int _day;	
};
void Func(Date d)
{
    
}
int main()
{
	Date d1;
	Func(d1);
	return 0;
}

上面说的大家可能不太理解,我们设计了上面的代码,打印出来的结果是“copy creat”,这充分说明我们的对象就是通过调用了拷贝构造进行初始化的。我们在调用Func函数的时候,实参d1传值给了d,这里发生了拷贝构造,利用d1构造出了d。

运算符重载

自定义类型是不能用运算符的,要用就得实现运算符重载。

内置类型是编译器自己定义的,所以它知道如何比较大小,但是比较自定义类型的时候,编译没办法进行,类型是我们自己定义的,编译器并不知道比较的规则,所以我们需要自己进行定义。

C 为了增强代码的可读性引入了运算符的重载,运算符重载是具有特殊函数名的函数.

函数原型

代码语言:javascript复制
返回值类型 operator操作符(参数列表)

运算符有几个操作数,operator重载的函数就有几个参数。

代码语言:javascript复制
//运算符重载
bool operator == (const Date& d)//这里有一个隐含的this指针
{
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}

运算符的重载是为了让自定义类型可以像内置类型一样去使用运算符。

拷贝构造是在对象创建的时候用另一个对象去初始化它,而赋值运算符重载是为了实现两个都已经初始化好的对象之间进行赋值。

所以赋值跟拷贝构造无关。

代码语言:javascript复制
Date d1 , d2;
d1 = d2;//两个对象都已经存在且初始化好了,这里是赋值构造
Date d3(d1);
Date d4 = d1;//这两个都是拷贝构造,利用一个对象去构造另一个对象,两种写法是等价的

其实,如果我们不写拷贝构造和赋值有时候也可以,因为它们都是默认函数,我们如果没有实现,那么编译器会自动生成并且调用。比如说日期类等。

但是还是存在特殊情况,对于类中的成员变量存在指针的时候就会出现问题,比如我们用类来实现一个栈,如果我们没有自己实现拷贝构造,那么在进行拷贝构造时,会按字节进行拷贝,会有两个数组指针指向同一块空间,由于析构函数会在程序结束时自动调用,完成代码的清理工作,所以一块空间会被连续释放两次,这是不被允许的!!!会引发程序的崩溃。这就是经典的浅拷贝问题!

我们在实现这类函数的时候,都要自己实现==“深拷贝”==的拷贝构造。

不调用析构进行空间释放就会导致内存泄漏,调用析构函数程序就会崩溃,所以我们自己实现深拷贝是必然的。

浅拷贝问题:同一块空间释放了两次,导致程序崩溃,我们后期会进行细致学习。


const修饰成员函数

代码语言:javascript复制
const Date* p1 //*p1指向的对象
Date const* p2 //*p2指向的对象
Date* const p3 //指针本身能被修改

只要成员函数中不直接或者间接改变成员变量,建议都加上const

const对象和非const对象都可以调用const修饰的函数


初始化列表

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

在定义的时候必须要进行初始化的变量必须使用初始化列表进行初始化。

为什么会存在初始化列表?

类中包含以下成员,必须放在初始化列表位置进行初始化: 1.引用成员变量 2.const成员变量 3.自定义类型成员(该类没有默认构造函数)

成员变量在类中声明顺序就是其在初始化列表中的初始化顺序,与其在初始化列表中的顺序无关。所以,实际中建议声明和定义的顺序一致。

尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。


static修饰变量知识

声明为static的类成员称为类的静态成员,这种方式定义的变量全局只有一个,因此对它进行的操作都会是对这一个变量的操作。用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化

题目:计算一个类在执行过程中产生了多少对象

思路:类实例化出对象一定是通过构造函数或者拷贝构造,所以我们以这个为切入点进行计算。

如果直接定义一个全局变量,那么如果谁想修改就可以直接修改掉了,因此我们要保证封装性,所以我们把这个计数器定义为私有,只能通过接口函数进行访问。

代码语言:javascript复制
class A
{	
public:
	A()
	{
		_count  ;
	}
	A(const A& B)
	{
		_count  ;
	}
	int GetCount()
	{
		return _count;
	}
private:
	int _count = 0;//定义变量进行计数
};
int main()
{
	A a;
	A b;
	cout << a.GetCount() << endl;
	cout << b.GetCount() << endl;
	return 0;
}

我们理想的打印结果是2。因为一共实例化出了两个对象。结果却是1

这是因为我们创建的不同对象在每次构造的时候,都是不同的count,为了保证我们所有的操作都作用于同一个变量上就需要使用static修饰,使其变成静态成员变量。以下是修改策略:

代码语言:javascript复制
#include<iostream>
using namespace std;
class A
{	
public:
	A()
	{
		_count  ;
	}
	A(const A& B)
	{
		_count  ;
	}
	int GetCount()
	{
		return _count;
	}
private:
	static int _count;//声明   不是属于某个对象,是属于类的所有对象,属于这个类
};
int A::_count = 0;//定义
int main()
{
	A a;
	A b;
	cout << a.GetCount() << endl;//这里的count都是同一个,因此无论采用哪个对象调用函数取出的count值都是同一个
	return 0;
}

友元

友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。

例如下面代码,我们在实现string类的时候,如果想要直接把string对象使用cout输出就需要重载<<操作符,因此我们写出了下面代码.

代码语言:javascript复制
ostream& operator<<(ostream& out, const string& str)
{
	for (int i = 0; i < str.size; i  )//_size是类的私有成员
	{
		out << str[i];
	}
	return out;
}

如果定义在类外面,无法获取到类内部的私有成员,因此我们需要使用友元。 友元的格式:

代码语言:javascript复制
friend ostream& operator<<(ostream& out, const string& str)

友元的特性:

  • 友元函数可访问类的私有和保护成员,但不是类的成员函数。并且一个函数可以是多个类的友元函数
  • 友元函数的调用与普通函数的调用原理相同
  • 友元函数不能被const修饰
  • 友元关系不能被继承

ps:内部类:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。内部类就是外部类的友元类,但外部类不是内部类的友元。

增加耦合度,破坏了封装,所以友元不宜多用。

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2amlwl9dujdws

0 人点赞