【C++】类和对象——Lesson1

小羊在奋斗 2024-08-09 13:35:01 阅读 82

Hi~!这里是奋斗的小羊,很荣幸您能阅读我的文章,诚请评论指点,欢迎欢迎 ~~

💥💥个人主页:奋斗的小羊

💥💥所属专栏:C++

🚀本系列文章为个人学习笔记,在这里撰写成文一为巩固知识,二为记录我的学习过程及理解。文笔、排版拙劣,望见谅。


目录

1、类的定义1.1类定义格式1.2访问限定符1.3类域

2、实例化2.1什么是实例化2.2对象大小2.3 this指针

3、C++和C语言实现Stack对比4、类的默认成员函数4.1构造函数4.2析构函数4.3拷贝构造函数

1、类的定义

C语言结构体中只能定义变量,C++中结构体内不仅可以定义变量还可以定义函数。

1.1类定义格式

<code>class ClassName

{ -- -->

//类体:成员函数和成员变量

};

class为定义类的关键字,ClassName为类的名字,{ }中为类的主体类中的变量称为类的属性或成员变量,类中的函数称为类的方法或成员函数C++中struct也可以定义类,C++兼容C中struct的用法,同时也升级struct成了类,一般情况下还是常用class定义类定义在类里面的成员函数默认为inline

class Date

{

public:

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

{

_year = year;

_month = month;

_day = day;

}

private:

//为了区分成员变量,一般习惯

//给成员变量加一个标识

int _year;

int _month;

int _day;

};

//不再需要typedef,ListNode就可以代表类型

struct ListNode

{

int val;

ListNode* next;

};

类的两种定义方式:声明和定义全部放在类体中 / 类声明放在.h文件中,成员函数定义在.cpp文件中,成员函数名前需要加类名


1.2访问限定符

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

在这里插入图片描述

<code>public修饰的成员在类外可以直接被访问,protectedprivate修饰的成员在类外不能直接被访问访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现为止class定义成员没有被访问限定符修饰时默认privatestruct默认为public


1.3类域

类定义了一个新的作用域,类的所有成员都在类的作用域中,在类外定义成员时,需要使用::作用域操作符指明成员属于哪个类域类域影响的是编译的查找规则,编译器默认只会在局部和全局查找,只有指定类域才会到类域中区查找


2、实例化

2.1什么是实例化

用类类型在物理内存中创建对象的过程,称为类实例化出对象类是对象进行一种抽象描述,是一个模型一样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,用类实例化出对象时才会分配空间类和对象是一对多的关系,类就像设计图一样,不能存储数据

class Date

{ -- -->

public:

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

{

_year = year;

_month = month;

_day = day;

}

private:

//这里只是声明,没有开空间

int _year;

int _month;

int _day;

};


2.2对象大小

类实例化出的每个对象,都有独立的数据空间,每个对象都有各自独立的成员变量存储各自的数据,那成员函数是否被存储呢?

如果存储的话要存函数指针,但是函数指针不会变,也就是说每个对象中的函数指针都是一样的,如果类实例化很多对象成员函数指针就要被重复存储很多次,有点浪费。

其实函数指针是不需要存储的,函数指针是一个地址,编译器在编译链接时就找到函数的地址,不是在运行时找。

类实例化出对象中只存储成员变量,C++规定类实例化的对象也要遵从内存对齐的规则。

class A

{

public:

void Print()

{

cout << _ch << endl;

}

private:

char _ch;

int _i;

};

class B

{

public:

void Print()

{

//

}

};

class C

{ };

int main()

{

A a;

B b;

C c;

cout << sizeof(a) << endl;

cout << sizeof(b) << endl;

cout << sizeof(c) << endl;

return 0;

}

请添加图片描述

对象b和c没有成员变量,但大小还是1,给1字节是为了占位标识对象存在。


2.3 this指针

<code>class Date

{ -- -->

public:

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

{

_year = year;

_month = month;

_day = day;

}

void Print()

{

cout << _ch << endl;

}

private:

int _year;

int _month;

int _day;

char _ch;

int _i;

}

int main()

{

Date d1;

Date d2;

d1.Init(2024, 7, 27);

d1.Print();

d2.Init(2004, 11, 7);

d2.Print();

return 0;

}

Date类中有InitPrint两个成员函数,函数体中没有关于不同对象的区分,那当d1调用InitPrint函数时,该函数是如何知道应该访问的是d1对象还是d2对象呢?

编译器编译后,类的成员函数默认都会在形参的第一个位置增加一个当前类型的指针,叫做this指针void Init(Date* const this, int year, int month, int day)类的成员函数中访问成员变量本质都是通过this指针访问的,C++规定不能在实参和形参的位置显示的写this指针(编译时编译器会处理),但是可以在函数体内显示使用this指针this指针不能被修改,指向的内容可以修改this指针存在内存中的栈区(形参)

也就是说上面的代码本质是下面这样的,但不能这样写:

class Date

{

public:

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

{

this->_year = year;

this->_month = month;

this->_day = day;

}

void Print(Date* const this)

{

cout << this->_ch << endl;

}

private:

int _year;

int _month;

int _day;

char _ch;

int _i;

}

int main()

{

Date d1;

Date d2;

d1.Init(&d1, 2024, 7, 27);

d1.Print(&d1);

d2.Init(&d2, 2004, 11, 7);

d2.Print(&d2);

return 0;

}

| 例一:

下面程序编译运行结果是什么?

#include <iostream>

using namespace std;

class A

{

public:

void Print()

{

cout << "A::Print" << endl;

}

private:

int _a;

};

int main()

{

A* p = nullptr;

p->Print();

return 0;

}

p->Print();这句代码并没有对指针解引用,->这里是成员访问操作符,p是对象指针,值为nullptr,就像下面这样将对象地址传过去由this指针接收,因为p本身就是对象地址所以不用再取地址

Date d1;

d1.Print(&d1);

请添加图片描述

底层汇编代码是这样的:

在这里插入图片描述

上面程序编译运行结果是:正常运行

在这里插入图片描述

| 例二:

下面程序编译运行结果是什么?

<code>#include <iostream>

using namespace std;

class A

{ -- -->

public:

void Print()

{

cout << _a << endl;

cout << "A::Print" << endl;

}

private:

int _a;

};

int main()

{

A* p = nullptr;

p->Print();

return 0;

}

这个题的运行结果是:运行崩溃

和例一基本一致,运行崩溃的原因是:cout << _a << endl;,因为例一说了this指针为空指针,cout << this->_a << endl;对空指针解引用了。


3、C++和C语言实现Stack对比

面向对象三大特性:封装、继承、多态,通过下面的对比我们可以初步了解封装。

C语言实现Stack:

#include<stdio.h>

#include<stdlib.h>

#include<stdbool.h>

#include<assert.h>

typedef int STDataType;

typedef struct Stack

{

STDataType* a;

int top;

int capacity;

}ST;

void STInit(ST* ps)

{

assert(ps);

ps->a = NULL;

ps->top = 0;

ps->capacity = 0;

}

void STDestroy(ST* ps)

{

assert(ps);

free(ps->a);

ps->a = NULL;

ps->top = ps->capacity = 0;

}

void STPush(ST* ps, STDataType x)

{

assert(ps);

// 满了, 扩容

if (ps->top == ps->capacity)

{

int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;

STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *

sizeof(STDataType));

if (tmp == NULL)

{

perror("realloc fail");

return;

}

ps->a = tmp;

ps->capacity = newcapacity;

}

ps->a[ps->top] = x;

ps->top++;

}

bool STEmpty(ST* ps)

{

assert(ps);

return ps->top == 0;

}

void STPop(ST* ps)

{

assert(ps);

assert(!STEmpty(ps));

ps->top--;

}

STDataType STTop(ST* ps)

{

assert(ps);

assert(!STEmpty(ps));

return ps->a[ps->top - 1];

}

int STSize(ST* ps)

{

assert(ps);

return ps->top;

}

int main()

{

ST s;

STInit(&s);

STPush(&s, 1);

STPush(&s, 2);

STPush(&s, 3);

STPush(&s, 4);

while (!STEmpty(&s))

{

printf("%d\n", STTop(&s));

STPop(&s);

}

STDestroy(&s);

return 0;

}

C++实现Stack

#include <iostream>

#include <assert.h>

using namespace std;

typedef int STDataType;

class Stack

{

public:

// 成员函数

void Init(int n = 4)

{

_a = (STDataType*)malloc(sizeof(STDataType) * n);

if (nullptr == _a)

{

perror("malloc申请空间失败");

return;

}

_capacity = n;

_top = 0;

}

void Push(STDataType x)

{

if (_top == _capacity)

{

int newcapacity = _capacity * 2;

STDataType* tmp = (STDataType*)realloc(_a, newcapacity *

sizeof(STDataType));

if (tmp == NULL)

{

perror("realloc fail");

return;

}

_a = tmp;

_capacity = newcapacity;

}

_a[_top++] = x;

}

void Pop()

{

assert(_top > 0);

--_top;

}

bool Empty()

{

return _top == 0;

}

int Top()

{

assert(_top > 0);

return _a[_top - 1];

}

void Destroy()

{

free(_a);

_a = nullptr;

_top = _capacity = 0;

}

private:

// 成员变量

STDataType* _a;

size_t _capacity;

size_t _top;

};

int main()

{

Stack s;

s.Init();

s.Push(1);

s.Push(2);

s.Push(3);

s.Push(4);

while (!s.Empty())

{

printf("%d\n", s.Top());

s.Pop();

}

s.Destroy();

return 0;

}

C++中数据和函数都放到了类里面,通过访问限定符进行了限制,不能再随意通过对象直接修改数据,这是C++封装的一种体现,这是最重要的变化。这里分装的本质是一种更严格规范的管理,避免乱访问修改的问题C++中有一些相对方便的语法,比如Init 给缺省参数会方便很多,成员函数每次不需要传对象地址,因为this指针隐含的传递了,使用类型也不再需要typedef重定义类名


4、类的默认成员函数

默认成员函数就是用户没有显示实现,编译器会自动生成的成员函数称为默认成员函数。

一个类,我们不写的情况下编译器会默认生成以下6个成员函数,默认成员函数很重要,也比较复杂,我们要从两个方面去学习。

我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求编译器默认生成的函数不满意我们的需求,我们如何自己实现?

在这里插入图片描述


4.1构造函数

构造函数是特殊的成员数,需要注意的是,构造函数虽然名叫构造,但构造函数的主要任务并不是开空间创建对象,而是对象实例化时初始化对象。

我们常用的局部对象是栈帧创建时空间就开好了, 构造函数的本质是要替代我们以前<code>Stack和Date类中写的Init函数的功能,构造函数自动调用的特点就完美的替代了Init

构造函数的特点:

函数名和类名相同无返回值(返回值啥都不需要给,也不需要写void)对象实例化时系统会自动调用对应的构造函数构造函数可以重载如果类中没有显示定义构造函数,C++编译器会自动生成一个无参的默认构造函数,一旦用户显示定义编译器将不再生成无参构造函数、全缺省构造函数、我们不写构造函数时编译器默认生成的构造函数,都叫默认构造函数, 但是这三个函数有且只有一个存在,不能同时存在,无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义不传实参就可以调用的构造就叫默认构造

#include <iostream>

using namespace std;

class Date

{ -- -->

public:

// 1.⽆参构造函数

Date()

{

_year = 1;

_month = 1;

_day = 1;

}

// 2.带参构造函数

Date(int year, int month, int day)

{

_year = year;

_month = month;

_day = day;

}

// 3.全缺省构造函数

/*Date(int year = 1, int month = 1, int day = 1)

{

_year = year;

_month = month;

_day = day;

}*/

void Print()

{

cout << _year << "/" << _month << "/" << _day << endl;

}

private:

int _year;

int _month;

int _day;

};

int main()

{

// 如果留下三个构造中的第⼆个带参构造,第⼀个和第三个注释掉

// 编译报错:error C2512: “Date”: 没有合适的默认构造函数可⽤

Date d1; // 调⽤默认构造函数

Date d2(2025, 1, 1); // 调⽤带参的构造函数

// 注意:如果通过⽆参构造函数创建对象时,对象后⾯不⽤跟括号,否则编译器⽆法

// 区分这⾥是函数声明还是实例化对象

// warning C4930: “Date d3(void)”: 未调⽤原型函数(是否是有意⽤变量定义的?)

Date d3();

d1.Print();

d2.Print();

return 0;

}

我们不写,编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说是否初始化是不确定的,看编译器。对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要用初始化列表才能解决

typedef int STDataType;

class Stack

{

public:

Stack(int n = 4)

{

_a = (STDataType*)malloc(sizeof(STDataType) * n);

if (nullptr == _a)

{

perror("malloc申请空间失败");

return;

}

_capacity = n;

_top = 0;

}

private:

STDataType* _a;

size_t _capacity;

size_t _top;

};

// 两个Stack实现队列

class MyQueue

{

public:

//编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造,完成了两个成员的初始化

private:

Stack pushst;

Stack popst;

};

int main()

{

MyQueue mq;

return 0;

}

大多数情况下,构造函数都需要我们自己写,应写尽写


4.2析构函数

析构函数和构造函数功能相反, 析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,不需要我们管;C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作。

析构函数的功能类比我们之前Stack实现的Destroy功能,而像Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。

析构函数的特点:

析构函数名是在类名前加上字符~无参数无返回值(和构造类似,也不需要加void)一个类只能有一个析构函数,若未显示定义,系统会自动生成默认的析构函数对象生命周期结束时,系统会自动调用析构函数跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用它的析构函数我们显示写析构函数,对于自定义类型成员也会调用它的析构,也就是说自定义类型成员无论什么情况都会自动调用析构函数如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如Date;如果默认生成的析构就可以用,也就不需要显示写析构,如MyQueue;但是有资源申请时,一定要自己写析构,否则会造成资源泄露,如Stack一个局部域的多个对象,C++规定后定义的先析构

#include<iostream>

using namespace std;

typedef int STDataType;

class Stack

{

public:

//默认构造函数

Stack(int n = 4)

{

_a = (STDataType*)malloc(sizeof(STDataType) * n);

if (nullptr == _a)

{

perror("malloc申请空间失败");

return;

}

_capacity = n;

_top = 0;

}

//析构函数

~Stack()

{

cout << "~Stack()" << endl;

free(_a);

_a = nullptr;

_top = _capacity = 0;

}

private:

STDataType* _a;

size_t _capacity;

size_t _top;

};

// 两个Stack实现队列

class MyQueue

{

public:

//编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源

// 显⽰写析构,也会⾃动调⽤Stack的析构

/*~MyQueue()

{}*/

private:

Stack pushst;

Stack popst;

};

int main()

{

Stack st;

MyQueue mq;

return 0;

}

析构函数的意义主要在于相较于C语言我们不需要再写InitDestroy,方便了很多。


4.3拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造函数是一个特殊的构造函数。

拷贝构造的特点:

拷贝构造函数是构造函数的一个重载拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用(如果要传值传参,则会调用拷贝构造,如果拷贝构造函数第一个参数不是引用而是传值传参,而传值传参又要调用拷贝构造,无限递归)

#include <iostream>

using namespace std;

class Date

{

public:

Date(int year = 1, int month = 1, int day = 1)

{

_year = year;

_month = month;

_day = day;

}

//加上const保护被拷贝对象不被改变

//传引用一般都要加const

Date(const Date& d)

{

_year = d._year;

_month = d._month;

_day = d._day;

}

void Print()

{

cout << _year << "-" << _month << "-" << _day << endl;

}

private:

int _year;

int _month;

int _day;

};

int main()

{

Date d1(2024, 7, 29);

d1.Print();

//拷贝构造

Date d2(d1);

//Date d2(&d2, d1);

d2.Print();

return 0;

}

C++规定自定义类型对象进行拷贝行为(传值传参)必须调用拷贝构造,所以自定义类型传值传参和传值返回都要调用拷贝构造完成

(所以对于自定义类型来说,不建议使用传值传参,传引用传参可以减少拷贝)

若未显示定义拷贝构造,编译器会自动生成拷贝构造函数,自动生成的拷贝构造对内置类型成员变量拷贝构造对内置类型也处理)会完成值拷贝 / 浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用它的拷贝构造

也就是说即使我们不显示的写拷贝构造函数,编译器自动生成的拷贝构造也会将d1拷贝给d2

请添加图片描述

既然拷贝构造对内置类型和自定义类型都会用默认生成的拷贝构造完成拷贝,那我们还需要自己写拷贝构造吗?

肯定还是需要我们自己动手写的,如果用默认生成的拷贝构造完成对自定义类型的拷贝,会出现下面这种结果:

在这里插入图片描述

如果我们不显示实现拷贝构造,则编译器自动生成的拷贝构造会完成浅拷贝,会导致st1和st2⾥⾯的_a指针指向同⼀块资源,析构时会析构两次,程序崩溃

像<code>Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝 / 浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现MyQueue的拷贝构造。总结来说就是:如果一个类显示实现了析构并释放资源,那么它就需要显示写拷贝构造,否则不需要。

#include <iostream>

#include <stdlib.h>

#include <string.h>

using namespace std;

typedef int STDataType;

class Stack

{ -- -->

public:

Stack(int n = 4)

{

_a = (STDataType*)malloc(sizeof(STDataType) * n);

if (nullptr == _a)

{

perror("malloc fail");

return;

}

_capacity = n;

_top = 0;

}

void Push(STDataType x)

{

if (_top == _capacity)

{

STDataType* tmp = (STDataType*)realloc(_a, sizeof(STDataType) * 2 * _capacity);

if (nullptr == tmp)

{

perror("realloc fail");

return;

}

_a = tmp;

tmp = nullptr;

_capacity *= 2;

}

_a[_top] = x;

_top++;

}

Stack(const Stack& st)

{

//需要对_a指向资源创建同样大小的资源再拷贝值

_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);

if (nullptr == _a)

{

perror("malloc fail");

return;

}

memcpy(_a, st._a, sizeof(STDataType) * st._top);

_top = st._top;

_capacity = st._capacity;

}

~Stack()

{

cout << "~Stack()" << endl;

free(_a);

_a = nullptr;

_top = _capacity = 0;

}

private:

STDataType* _a;

int _top;

int _capacity;

};

//两个栈实现队列

class MyQueue

{

public:

//...

private:

Stack pushst;

Stack popst;

};

int main()

{

Stack st1;

st1.Push(1);

st1.Push(2);

st1.Push(3);

//如果我们不显示实现拷贝构造,则编译器自动生成的拷贝构造会完成浅拷贝

//会导致st1和st2⾥⾯的_a指针指向同⼀块资源,析构时会析构两次,程序崩溃

//下面这两种实现的效果一样

//Stack st2(st1);

Stack st2 = st1;

MyQueue mq1;

//MyQueue⾃动⽣成的拷⻉构造,会⾃动调⽤Stack拷⻉构造完成pushst/popst

//的拷⻉

//只要Stack拷⻉构造⾃⼰实现了深拷⻉,他就没问题

MyQueue mq2 = mq1;

return 0;

}

传值返回会产生一个临时对象调用拷贝构造,传引用返回返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似野指针。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回




声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。