【C++】类和对象(上)

文章目录

  • 上文链接
  • 一、类(class)
    • [1. 类的定义](#1. 类的定义)
    • [2. 类与结构体](#2. 类与结构体)
    • [3. 访问限定符](#3. 访问限定符)
    • [4. 类域](#4. 类域)
  • 二、对象
    • [1. 实例化](#1. 实例化)
    • [2. 对象大小](#2. 对象大小)
  • [三、this 指针](#三、this 指针)
    • [1. 什么是 this 指针](#1. 什么是 this 指针)
    • [2. 小练习](#2. 小练习)

上文链接

【C++】入门基础知识(下)

一、类(class)

1. 类的定义

类(class)很像 C 语言中的结构体,是一个复合类型,与结构体 (C语言) 最大的不同在于类中可以定义函数。类中的内容称为类的成员,类中的变量称为类的属性或成员变量,类中的函数称为类的方法或成员函数

class 为定义类的关键字,其后跟类的名字,类的主体用大括号 {} 括起来,最后以分号 ; 结束

cpp 复制代码
class test // 类的名称
{
	int a = 0; // 成员变量
    void f() // 成员函数
    {
		// ...
    }
}; // 分号不能省略

在 C 语言中,以栈的模拟实现为例,我们可以写出类似下面的代码

c 复制代码
typedef struct Stack 
{
    int* a; 
    int top;  
    int capacity; 
}ST;

void StackInit(ST* ps) 
{
    // ...
}

void StackDestroy(ST* ps) 
{
    // ...
}

void StackPush(ST* ps, int x) 
{
    // ...
}

// ...

而现在有了类,我们可以把实现栈的一系列变量和函数放在一个名为 Stack 的类中

当我们把这些变量和函数定义在了类之中,有以下特点

  • 由于这些函数都是定义在 Stack 这个类中,所以我们在为这些函数命名的时候也就不需要写 "Stack" 这个词了,只需要根据这些函数的功能来命名即可。比如 PushDestroy 等,更加方便
  • 那如果其他类中也有 Push 或者 Destroy 等操作会不会和这里的函数发生冲突呢?答案是不会。在 C++ 中作用域分四种:局部域、全局域、命名空间域以及类域。也就是说类形成了一个单独的域,而我们又知道不同的域是可以定义同名函数的,所以说不同的类中定义同名的函数不会发生冲突(关于类域后面还会提到)
  • 在以前的结构体中,变量和函数是分离的。但是在类里面我们将它们放在了一起,而在同一个类之中成员函数是可以访问到成员变量的。因此我们在函数传参的时候可以不需要传入某些参数
  • 成员变量可以定义在成员函数之后,这是合法的。因为编译器会把类当作一个整体,如果编译器在某个函数中发现某个成员变量在之前没有出现过,它会在整个类中去寻找
  • 一般为了区分成员变量,一般习惯上会给成员变量加上一个特殊标识,比如在成员变量名字前面或者后面加 _ 或者 以m 开头定义成员变量(m 即 member的缩写)。这一点不是 C++ 的语法要求,而是一些惯例。比如,如果变量 a 是成员函数,我们一般写成 _aa_ 或者 m_a
  • 定义在类中的成员函数默认为 inline,如果声明和定义分离就不是内联函数
cpp 复制代码
class Stack
{
    // 成员函数
    void Init()  // 不需要写成 StackInit
    // 而原本的参数 ps 是用来访问数组 a 的, 现在 a 在类中可以直接访问,所以不需要传入参数
    {
        // ...
    }

    void Destroy() // 这些函数都是内联函数,因为定义在类里面
    {
        // ...
    }

    void Push(int x)
    {
        // ...
    }
    
    // 成员变量, 定义在这里是完全没问题的
    int* _a; // 加上特殊标识表示这是成员变量, 也可以是 a_, m_a
    int _top; 
    int _capacity; 
};

class Queue
{
	// ...
    
    void Init() // 不会和 Stack 中的 Init 函数冲突
    {
		// ...
    }
};

2. 类与结构体

在 C++ 中,结构体升级成了 "类",所以在 C++ 中, struct 中也是可以定义函数的,但是一般情况下还是建议用 class 来定义类

cpp 复制代码
struct test
{
	int a = 0;
    void f() 
    {
		// ...
    }
};

此外,在 C 语言中,若结构体没有用 typedef 的话那么 struct + 结构体名称才是该结构体的类型;但是类的名字就是类的类型

但是,由于在 C++ 中结构体的升级,就算没有用 typedef 来定义结构体,也可以直接用结构体的名字作为它的类型,当然你也可以加上前面的 struct,因为 C++ 兼容 C 语言,不过不加的话更规范

cpp 复制代码
struct Stack_1
{
    // ...
};

class Stack_2
{
    // ...
};

int main()
{
    struct Stack_1 ST;  // OK
    Stack_1 ST;  // OK
    Stack_2 ST;  // OK

    return 0;
}

3. 访问限定符

当我们定义出来一个类之后,类的属性(成员变量)和方法(成员函数)也就结合到了一块儿。那么我们还可以通过一种叫访问限定符的东西来控制外部用户的访问权限,选择性地将类中的接口提供给外部的成员使用

访问限定符有三种:privateprivateprotected

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

  • privateprotected 修饰的成员在类外不能直接被访问,在这里暂且认为它们两个是一样的,在之后学习继承的时候才能体现出它们的区别

一个访问限定符的修饰范围为从它开始到下一个访问限定符为止,如果后面没有访问限定符了,就到类结束

cpp 复制代码
class Stack
{
public:
    // 成员函数
    void Init() 
    {
        // ...
    }

    void Destroy()
    {
        // ...
    }

    void Push(int x)
    {
        // ...
    }
// 直到下一个访问限定符之前, 以上所有的函数均被 public 修饰, 在类外可以直接被调用。
private:
    // 成员变量
    int* _a;
    int _top; 
    int _capacity; 
// 如果后后面没有限定符, 那么访问限定符的修饰范围就到整个类结束。
};

我们一般都不希望外部直接访问到我们的成员变量,所以我们一般会把成员变量设置成私有的。类中的方法(函数)一般可以设置成公有,但是如果你不想让别人访问,也可以设置成私有

如果class中没有访问限定符修饰,都默认为 private;struct 中默认为 public

cpp 复制代码
class Stack
{
    void Init() // 这个函数没有被访问限定符修饰,所以默认为 private
    {
        // ...
    }
public:
    void Destroy()
    {
        // ...
    }
    
private:
    int* _a;
    int _top; 
    int _capacity; 
};

4. 类域

作用域分四种:局部域、全局域、命名空间域以及类域。局部域和全局域会影响变量的生命周期,而命名空间域和类域则不会。

在之前学习命名空间的时候我们知道,编译器查找某个对象的优先顺序是先去局部域找再去全局域找,它是不会自己去命名空间域和类域中寻找的。所以如果我们在实现函数进行声明和定义分离时,必须指定类域

cpp 复制代码
// Stack.h
class stack
{
public:
    void Init();
  
private:
    int* a;
    int top;
    int capacity;
};

// Stack.cpp
#include"Stack.h"

void stack::Init() // 这里必须指定类域 stack
{
    a = nullptr;
    top = 0;
    capacity = 0;
}

但是,有些短小的函数就不建议声明和定义分离。因为短小的函数适合作为内联函数,从而提高效率。

cpp 复制代码
// Stack.h
class stack
{
public:
    void Init();
    int Top() { return a[top - 1]; };  // 直接定义在类里面, 变成内联函数
  
private:
    int* a;
    int top;
    int capacity;
};

二、对象

1. 实例化

还是以栈地模拟实现为例

cpp 复制代码
// Stack.h
class stack
{
public:
    void Init();
    int Top() { return a[top - 1]; };
    void Destroy();
    void Push(int x);
  
private:
    int* a;
    int top;
    int capacity;
};
cpp 复制代码
// Stack.cpp
#include"Stack.h"

void stack::Init()
{
    a = nullptr;
    top = 0;
    capacity = 0;
}

void stack::Destroy()
{
    free(a);
    a = nullptr;
    capacity = top = 0;
}

void stack::Push(int x)
{
    if (top == capacity)
    {
        int newCapacity = capacity == 0 ? 4 : capacity * 2;
        int* tmp = (int*)realloc(a, sizeof(int) * newCapacity);
        if (tmp == nullptr)
        {
            printf("realloc fail\n");
            exit(-1);
        }
        a = tmp;
        capacity = newCapacity;
    }
    a[top] = x;
    top++;
}

和结构体类似,当我们完整地定义好了一个类之后我们可以根据这个类来创建一个变量。只不过在 C++ 中我们一般不把这个创建出来的东西叫 "变量",而是叫 "对象"

cpp 复制代码
// test.cpp
#include"Stack.h"

int main()
{
    stack ST_1;  // 创建出了一个 "对象"
    stack ST_2;
    // ...

    return 0;
}

我们在实现类的时候,仅仅只是给成员变量一个声明,并没有为它们分配空间。只有当我们使用 stack ST 创建了对象之后,才分配了空间。

而用类这种类型在物理内存中创建对象的过程,称为类实例化出对象

类就像设计图纸一样,它规定了某个对象的设计标准是怎样的,但是它还没有去实现,没有产生实体。而实例化对象就是根据这张设计图纸去实际创造出这一个对象。类中的成员变量决定了这个对象所具有的属性,而成员函数决定了这个对象能干什么


2. 对象大小

当我们实例化出对象之后,这个对象有多大

类和结构体很像,在 C 语言中我们计算结构体的大小时要符合内存对齐的规则。在 C++ 中计算对象的大小时也不例外

内存对齐的规则如下

  • 第一个成员在与结构体偏移量为 0 的地址处
  • 其他变量要对齐到某个数字(对齐数)的整数倍的地址处
  • 对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值
  • VS 中默认的对齐数为 8
  • 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍
  • 如果嵌套了结构体,那么嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
cpp 复制代码
// Stack.h
class stack
{
public:
    void Init();
    int Top() { return a[top - 1]; };
    void Destroy();
    void Push(int x);
  
private:
    int* a;
    int top;
    int capacity;
};

我们可以利用内存对齐的规则计算出上面 stack 类的成员变量的大小为 16。那么成员函数呢?

假如说现在我们创建了一个对象,那么这两个对象里面存储了它的成员变量,那它对应的成员函数的指针会不会存在这个对象里面呢?答案是不会

为什么?我们来看下面这个例子

cpp 复制代码
#include"Stack.h"

using namespace std;

int main()
{
    stack st1;
    stack st2;

    // st1.top;
    st1.Init();
   
    // st2.top;
    st2.Init();

    return 0;
}

我们现在实例化出两个对象,那么这两个对象的 top 是同一个吗?不是同一个,不同的对象肯定是有各自独立的成员变量要存不同的数据的。那这里调用的两个函数 Init() 是同一个吗?是同一个。当我们设置断点查看调用函数的地址的时候会看到它们都 call 的是同一个函数的地址,即它们调用的都是同一个函数

所以其实这些函数的指针都是一样的,存储在对象中就浪费了。如果我创建 1000 个对象,那么这个指针就重复存储了 1000 次

需要补充的是,函数被编译后是一段指令,对象中没法储存,这些指令会存储在一个单独的区域(代码段),如果对象中非要存储的话,只能是函数的指针

函数指针是一个地址,调用函数就是函数被编译成汇编指令然后去 call 这个地址。所以编译器在编译链接时,就要找到这个地址,不是在运行的时候找,所以这里存指针也没啥用。只有动态多态是在运行的时候找,就需要存储函数的地址,这个我们不展开

最后,对于一个空类,或者没有成员变量的类,它所创建出来的对象的大小为 1,目的是占位,表示对象存在

cpp 复制代码
class A
{};

class B
{
	void f()
    {
        // ...
    }
};

int main()
{
    cout << sizeof(A) << endl;  // 1
    cout << sizeof(B) << endl;  // 1
    
	return 0;
}

三、this 指针

1. 什么是 this 指针

我们先来看看这段代码

cpp 复制代码
#include"Stack.h"

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

private:
    int _year;
    int _month;
    int _day;
};


int main()
{
    Date d1;
    Date d2;

    d1.Init(2024, 9, 27);
    d2.Init(2025, 4, 18);
    d1.print();
    d2.print();

    return 0;
}

上面的代码运行结果如下

根据上面的学习我们知道,这两个对象 d1d2 调用的是同一个函数,但是它们所运行的结果却不同。为什么呢?你可能会说它们调用的函数内部的成员变量不同,那这个函数是怎么访问到这些成员变量的呢?实际上就是依靠 this 指针来实现的

事实上,我们看起来这个 print 函数没有任何参数,但实际上在内部是有一个叫 this 的指针传入这个函数,通过这个指针来访问到我们的成员变量的,就像下面这样

cpp 复制代码
void print(Date* const this) // 关于此处的 const 下面会有解释
{
    cout << this->_year << '/' << this->_month << '/' << this->_day << endl;
}

d1.print(&d1);

所以说调用同一个函数打印出来的东西不一样是因为编译器悄悄把该对象的地址传给 this 指针,通过 this 指针来访问到所对应的成员变量然后打印出来

不仅仅是这个 print 函数,类中其他的函数都会隐藏一个 this 指针,比如这里的 Init 函数

cpp 复制代码
void Init(Date* const this, int year, int month, int day)
{
    this->_year = year;
    this->_month = month;
    this->_day = day;
}

也就是说,实际上看起来一个成员函数没有形参,实际上它有一个参数 this。看起来只有 3 个参数,实际上还有一个参数 this,只不过这个事情是编译器悄悄做的

那要是我们写成员函数的时候把这个 this 指针写在形参上呢?不行,不能显式地传 this!但是话又说回来,虽然形参里面不能显式地去传 this,但是我们在类中可以使用 this,你不传 this 在形参中,但是你可以在函数内用 this-> 访问成员变量,但是这完全没必要,因为你不写编译器也会自动加。但是有时候我们会用 this 指针来解决一些特殊的问题,必须要用,这里先不展开


  • 补充:关于指针的两个 const

int* const p:const 在 * 的右边,修饰的是指针,表示指针本身不可变,并且这种定义下的指针必须要初始化

const int*p:const 在 * 的左边,修饰的是值,表示指针所指的内容不可变

所以说这里隐含的 this 指针所加的 const 意思就是 this 指针本身不能改变


2. 小练习

  • 题目一

下面程序的运行结果是( )

A. 编译报错

B. 运行崩溃

C. 正常运行

cpp 复制代码
#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;
}

我们来看看运行结果

答案:C

为什么这段代码能正常运行?难道 p->print() 这个操作不是空指针的访问?不是的。因为这里根本就没有发生解引用操作。这里的 -> 指的意思实际上是告诉编译器我这个 print 函数是在 p 所对应的类中的一个成员函数,让编译器能找到这个函数的出处,并不是解引用去访问一个空指针。还有就是注意我们之前所讲到的一个点:成员函数的指针是没有放到对象里面的,结合这一点也可以解释这里并不是一个空指针的解引用行为。

另外一个问题是,我们类中的函数其实是会传一个 this 指针的,而这里的 this 指针就是这个 p,它是一个空指针。但是函数内部并没有对这个空指针进行解引用的操作,所以能正常运行

p->print() 等价于 A::print(p)


  • 题目二

下面程序的运行结果是( )

A. 编译报错

B. 运行崩溃

C. 正常运行

cpp 复制代码
#include<iostream>

using namespace std;

class A
{
public:
	void print()
	{
		cout << "A::print()" << endl;
        cout << _a << endl;
	}

private:
	int _a;
};

int main()
{
	A* p = nullptr;
	p->print();

	return 0;
}

答案:B

这道题和上面一道题是一个孪生兄弟,仅仅增加了一行代码,但是却又截然不同的效果。因为我们在上一道题中讲到说这个 print 函数实际上是传了一个 this 指针的,就是这个 p。现在这个函数中有一个成员变量 _a,那么我们就需要通过 this 指针去访问这个成员变量。而现在这个 this 指针是一个空指针,访问 _a 的时候实际上是 this->a,这才是属于空指针的解引用行为,所以会导致运行崩溃(注意不是编译报错,空指针的访问不是编译报错)


  • 题目三

this 指针存在内存哪个区域( )

A. 栈

B. 堆

C. 静态区

D. 常量区

E. 对象里面

首先这道题应该首先排除 E,因为之前我们就说过我们的对象只存储了成员变量,所以 this 指针肯定不会存储在对象里面。接着 C、D 选项,静态数据、全局数据存在静态区,常量数据比如字符串等存储在常量区,显然不是这两个选项,排除。对于 A、B 选项,局部数据存储在栈中, 动态开辟的数据存储在堆中。而我们的 this 指针是一个形参,而函数的参数是存在栈帧里的,因此答案选 A。

答案:A

补充:在 VS 中,由于要频繁访问 this 指针,会将 this 指针存在寄存器中

相关推荐
我真的不会C41 分钟前
QT中的事件及其属性
开发语言·qt
Ethon_王1 小时前
走进Qt--工程文件解析与构建系统
c++·qt
2501_906314322 小时前
优化无头浏览器流量:使用Puppeteer进行高效数据抓取的成本降低策略
开发语言·数据结构·数据仓库
工藤新一¹2 小时前
C++/SDL进阶游戏开发 —— 双人塔防游戏(代号:村庄保卫战 13)
c++·游戏·游戏引擎·毕业设计·sdl·c++游戏开发·渲染库
好想有猫猫2 小时前
【Redis】服务端高并发分布式结构演进之路
数据库·c++·redis·分布式·缓存
不是杠杠2 小时前
驼峰命名法(Camel Case)与匈牙利命名法(Hungarian Notation)详解
c++
Epiphany.5562 小时前
基于c++的LCA倍增法实现
c++·算法·深度优先
落羽的落羽2 小时前
【落羽的落羽 C++】vector
c++
magic 2452 小时前
深入解析Promise:从基础原理到async/await实战
开发语言·前端·javascript