de风——【从零开始学C++】(二):类和对象入门(一)

大家好,我是你们的小小风呀!上一篇我们搞定了 C++ 的基础语法,今天我们正式进入面向对象的世界啦!

这是 C++ 最核心的特性,之前我们写的都是面向过程的代码,按步骤一步步执行,但是当程序变大之后,这样的代码会越来越难维护。而面向对象,就是把现实世界的事物抽象成代码里的类和对象,把数据和操作数据的方法打包在一起,让代码更清晰、更安全、更好维护。

今天我们先从最基础的类的定义、实例化开始,最后我们还会用 C 和 C++ 分别实现一个栈,直观感受一下面向对象的优势!全程大白话 + 简单代码,新手也能看懂!


一、类的定义

我们之前学 C 语言的时候,用过结构体,它可以把一组变量打包在一起,但是它只能放数据,不能放操作数据的函数。而 C++ 的类,就是结构体的超级升级版,它不仅能放数据(属性),还能放操作数据的函数(行为),把它们打包成一个整体,这就是封装的基础。

1. 类定义格式

基础概念

类的定义语法很简单,用++class++关键字,后面跟类名,然后大括号里放类的成员,最后结尾必须加一个分号!这是新手最容易忘的错误,一定要记牢!

类的成员分为两种:

  • 成员变量:也就是类的属性,描述这个类的特征,比如学生的姓名、年龄

  • 成员函数:也就是类的方法,描述这个类能做什么,比如学生能学习、能吃饭

还有一个行业惯例,成员变量的名字一般会加一个_前缀,比如_name_age,用来和普通的局部变量、形参区分开,这个不是语法强制的,但是大家都这么写,方便阅读。

示例 1:定义一个简单的学生类,了解类的基本格式

这个例子会帮你快速了解类的基本结构,我们定义一个学生类,包含学生的属性和行为。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 定义学生类:class是关键字,Student是类名
class Student {
public:
    // 成员变量:学生的属性,加_前缀区分
    string _name;
    int _age;
    
    // 成员函数:学生的行为
    void study() {
        cout << _name << "正在学习C++!" << endl;
    }
}; // 注意!结尾必须加分号!新手最容易忘!

int main() {
    // 用类创建对象,也就是实例化,类名直接当类型用
    Student s;
    // 给对象的属性赋值
    s._name = "小明";
    s._age = 18;
    // 调用对象的方法
    s.study();
    
    return 0;
}

运行结果:

cpp 复制代码
小明正在学习C++!

你看,是不是很简单?我们把学生的名字、年龄,还有他能做的学习行为,都打包在了一个类里,用的时候直接创建对象,调用方法就行,比 C 语言的结构体好用太多了。

2. 访问限定符

基础概念

刚才我们的例子里,所有成员都是 public 的,也就是外部可以直接访问修改,但是这样的话,外部可以随便给年龄改个负数,比如s._age = -10,这显然不合理,对吧?

所以 C++ 提供了访问限定符,用来控制类的成员能不能被外部访问,实现封装的核心思想:隐藏内部的细节,只暴露必要的接口,保证数据的安全性。

C++ 有三种访问限定符:

|---------------|---------|---------|---------------------------|
| 限定符 | 类外部能否访问 | 类内部能否访问 | 核心用途 |
| public(公有) | ✅ 可以 | ✅ 可以 | 暴露对外的接口,比如我们要让外部调用的方法 |
| private(私有) | ❌ 不可以 | ✅ 可以 | 隐藏内部的细节,比如成员变量,不让外部随便改 |
| protected(保护) | ❌ 不可以 | ✅ 可以 | 用于继承,子类可以访问,外部不行,新手暂时不用深入 |

还有一个关键规则:访问限定符的作用域,是从它出现的位置,到下一个限定符出现的位置 ,或者到类结束。而且,用class定义的类,默认的访问权限是 private,而用struct定义的话,默认是 public,这个新手很容易踩坑!

class-->默认private

struct-->默认public

示例 2:访问限定符的使用,理解封装的意义

这个例子会展示怎么用访问限定符实现封装,把年龄藏起来,只通过接口修改,还能做合法性校验。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Student {
private:
    // 私有成员:外部不能直接访问,隐藏内部数据
    string _name;
    int _age;
    
public:
    // 公有接口:外部可以调用,用来操作私有成员
    // 设置姓名的方法
    void set_name(string name) {
        _name = name;
    }
    
    // 设置年龄的方法,还能做合法性校验!
    void set_age(int age) {
        // 年龄不能小于0,也不能大于150,无效的话就设为0
        if (age >= 0 && age <= 150) {
            _age = age;
        } else {
            cout << "年龄不合法!" << endl;
            _age = 0;
        }
    }
    
    // 获取姓名和年龄的方法
    string get_name() {
        return _name;
    }
    
    int get_age() {
        return _age;
    }
    
    void study() {
        cout << _name << "今年" << _age << "岁,正在学习C++!" << endl;
    }
};

int main() {
    Student s;
    
    // ❌ 错误!_age是私有成员,外部不能直接访问!
    // s._age = 18;
    
    // ✅ 正确,通过公有接口来设置
    s.set_name("小明");
    s.set_age(18);
    cout << "姓名:" << s.get_name() << endl;
    cout << "年龄:" << s.get_age() << endl;
    s.study();
    
    // 测试无效年龄
    cout << endl << "测试无效年龄:" << endl;
    s.set_age(200);
    cout << "年龄:" << s.get_age() << endl;
    
    return 0;
}

运行结果:

cpp 复制代码
姓名:小明
年龄:18
小明今年18岁,正在学习C++!

测试无效年龄:
年龄不合法!
年龄:0

你看,这就是封装的魔力!我们把内部的成员变量藏起来,外部不能随便改,只能通过我们提供的接口来操作,这样我们就能在接口里做校验,避免无效的数据,保证了数据的安全性,这比把所有成员都设为 public 要安全太多了。

3. 类域

基础概念

我们之前学过,命名空间有自己的作用域,类也一样!类定义了一个独立的作用域,叫做类域,类里面所有的成员都在这个域里,和外部的名字不会冲突。

这就意味着,如果我们要在类的外面定义类的成员函数,就必须用::作用域解析符,告诉编译器,这个函数是属于这个类的,不然编译器会把它当成全局函数,找不到类里的私有成员。

示例 3:类域的使用,学会类内声明类外定义成员函数

这个例子会展示怎么把成员函数的声明和定义分开,这在大项目里很常用,头文件放声明,源文件放定义。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Student {
private:
    string _name;
    int _age;
public:
    // 类内只写函数的声明,不写实现
    void set_name(string name);
    void set_age(int age);
    string get_name();
    int get_age();
    void study();
};

// 类外定义成员函数,必须加Student::,指明这是Student类域里的函数
// 不然编译器会当成全局函数,找不到私有成员!
void Student::set_name(string name) {
    _name = name;
}

void Student::set_age(int age) {
    if (age >= 0 && age <= 150) {
        _age = age;
    } else {
        cout << "年龄不合法!" << endl;
        _age = 0;
    }
}

string Student::get_name() {
    return _name;
}

int Student::get_age() {
    return _age;
}

void Student::study() {
    cout << _name << "今年" << _age << "岁,正在学习C++!" << endl;
}

int main() {
    Student s;
    s.set_name("小红");
    s.set_age(20);
    s.study();
    return 0;
}

运行结果:

cpp 复制代码
小红今年20岁,正在学习C++!

你看,这样我们就把声明和定义分开了,代码更整洁,这就是类域的作用,它让类的成员和外部的成员隔离开,不会冲突。


二、类的实例化

讲完了类的定义,我们来讲怎么用类创建对象,这个过程就叫做实例化

1. 实例化的概念

基础概念

类其实只是一个模板,一个蓝图,它是抽象的概念,本身不占用内存。比如我们定义的 Student 类,只是定义了学生有什么属性、什么行为,但是它本身没有具体的姓名、年龄,也不占内存。

而实例化,就是用这个模板,创建出具体的对象,对象才是真实存在的,会占用内存。一个类可以实例化出无数个对象,每个对象都有自己独立的成员变量,但是共享类的成员函数。

打个比方,类就像是月饼的模子,对象就是用模子做出来的月饼,一个模子可以做很多月饼,每个月饼都有自己的大小、口味,但是它们都是按照同一个模子做出来的。

示例 4:类的实例化,一个类创建多个对象

这个例子会展示怎么用一个类创建多个独立的对象,每个对象有自己的属性。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Student {
private:
    string _name;
    int _age;
public:
    void set_info(string name, int age) {
        _name = name;
        _age = age;
    }
    void show() {
        cout << "姓名:" << _name << ",年龄:" << _age << endl;
    }
};

int main() {
    // 实例化两个学生对象,小明和小红
    Student s1;
    Student s2;
    
    // 给小明赋值
    s1.set_info("小明", 18);
    // 给小红赋值
    s2.set_info("小红", 20);
    
    // 两个对象的属性是独立的,互不影响
    cout << "小明的信息:";
    s1.show();
    cout << "小红的信息:";
    s2.show();
    
    return 0;
}

运行结果:

cpp 复制代码
小明的信息:姓名:小明,年龄:18
小红的信息:姓名:小红,年龄:20

你看,我们用同一个 Student 类,创建了两个对象,它们的属性是独立的,修改 s1 的属性不会影响 s2,但是它们都用同一个 show 函数,这就是实例化的作用。

2. 对象大小

基础概念

很多新手会好奇,一个对象的大小是怎么算的?其实很简单,对象的大小,只算成员变量的大小成员函数是不算的!因为成员函数存在代码段,整个类共享,每个对象不用存一份,不然如果有 1000 个对象,每个对象都存一份函数的代码,那也太浪费内存了。

而且,对象的内存对齐规则,和 C 语言的结构体是完全一样的!因为对象本质就是升级版的结构体而已。对齐规则我们之前其实提过,再给新手复习一下:

  1. 第一个成员,偏移地址是 0

  2. 其他成员,要对齐到它的对齐数的整数倍地址,对齐数是「成员自身大小」和「编译器默认对齐数」的较小值,VS 默认是 8,GCC 默认是 4

  3. 整个对象的总大小,必须是最大对齐数的整数倍

还有一个特殊情况,空类,也就是没有成员变量的类,它的大小是 1 字节,用来占位,标识这个对象存在,不然就没有大小了。

示例 5:对象大小的计算,理解内存对齐

这个例子会帮你理解对象的大小是怎么算的,还有内存对齐的规则。

cpp 复制代码
#include <iostream>
using namespace std;

// 空类,没有成员变量
class Empty {};

class A {
private:
    char _c; // 1字节
    int _i;  // 4字节
public:
    void func() {} // 成员函数,不占对象的大小
};

int main() {
    // 空类的大小是1字节,占位用
    cout << "空类的大小:" << sizeof(Empty) << endl;
    
    // A类的大小:char1字节,然后填充3字节,然后int4字节,总共8字节
    cout << "A类对象的大小:" << sizeof(A) << endl;
    
    return 0;
}

运行结果:

cpp 复制代码
空类的大小:1
A类对象的大小:8

是不是很清楚?成员函数不占对象的大小,只有成员变量占,而且遵循内存对齐的规则,和结构体一模一样。

3. this 指针

基础概念

刚才我们说,多个对象共享同一个成员函数,那函数怎么知道,当前操作的是哪个对象的成员变量呢?比如 s1.study () 和 s2.study (),调用的是同一个函数,为什么一个打印小明,一个打印小红?

这就要用到this 指针 了!编译器会自动给每个非静态成员函数,加一个隐含的参数 ,就是 this 指针,这个指针指向调用这个函数的对象。当你访问成员变量的时候,其实是this->成员变量,只是我们不用写,编译器自动帮我们加了。

this 指针的特性:

  • 它是隐含的,我们不能在参数列表里显式写它,但是可以在函数里用它

  • 它是const指针,不能修改它的指向,也就是不能把 this 改成别的对象的地址

  • 它是自动传的,调用函数的时候,编译器会把对象的地址当成 this 指针的实参传进去

示例 6:this 指针的作用,解决成员变量和形参同名的问题

这个例子会展示 this 指针的用法,最常用的就是解决成员变量和形参同名的问题。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Student {
private:
    string _name;
    int _age;
public:

    // 形参名和成员变量名一样了,这时候用this指针区分
    void set_info(string _name, int _age) 
    {
        // this->成员,就是当前对象的成员,右边的是形参
        this->_name = _name;
        this->_age = _age;
    }
    //也可以直接省略this,让编译器自动识别,但要保证函数的形参和类的成员变量名不一样
    //void set_info(string name, int age)
    //{
	//    _name = name;
	//    _age = age;
    //}



    void show() {
        // 其实这里也隐含了this,相当于this->_name
        cout << "姓名:" << _name << ",年龄:" << _age << endl;
        // 我们也可以显式用this,比如打印当前对象的地址
        cout << "当前对象的地址:" << this << endl;
    }
};

int main() {
    Student s1;
    Student s2;
    
    s1.set_info("小明", 18);
    s2.set_info("小红", 20);
    
    cout << "s1的地址:" << &s1 << endl;
    s1.show();
    
    cout << endl;
    
    cout << "s2的地址:" << &s2 << endl;
    s2.show();
    
    return 0;
}

运行结果:

cpp 复制代码
s1的地址:0x7ffd7b9ff1b0
姓名:小明,年龄:18
当前对象的地址:0x7ffd7b9ff1b0

s2的地址:0x7ffd7b9ff1a0
姓名:小红,年龄:20
当前对象的地址:0x7ffd7b9ff1a0

你看,是不是很明显?调用 s1 的函数的时候,this 指针就指向 s1,调用 s2 的函数的时候,this 就指向 s2,所以函数就能区分开,操作的是哪个对象的成员了,这就是 this 指针的作用。


三、实战练习:用 C 和 C++ 分别实现栈

讲了这么多理论,我们来做个实战练习,分别用 C 语言和 C++ 实现一个栈,直观感受一下面向对象的优势,你就能明白,为什么我们要用类了。

1. C 语言实现栈(面向过程)

C 语言里,我们要实现栈,只能用结构体存数据,然后写一堆全局的函数,操作这个结构体,数据和函数是分离的,而且结构体的成员外部可以随便改,很不安全。

示例 7:C 语言实现栈,对比面向过程的写法

这是 C 语言的栈实现,我们来看看它的写法。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// C语言的结构体,只能存数据
typedef int STDataType;
typedef struct Stack
{
	STDataType* a;
	int top;
	int capacity;
}ST;

//栈的初始化
void STInit(ST* ps)
{
	assert(ps);
	ps->a = NULL;
	ps->capacity = 0;
	ps->top = 0;
}

//栈的销毁
void STDestroy(ST* ps)
{
	assert(ps);

	free(ps->a);
	ps->a = NULL;
	ps->capacity = ps->top = 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;
}

你看,C 语言的写法,数据和函数是分离的,每个函数都要传结构体的指针,很麻烦,而且外部可以随便修改结构体的内部成员,很容易把数据改坏,没有任何保护。

2. C++ 实现栈(面向对象)

而用 C++ 的类来实现栈,我们把数据和函数都封装在类里,数据设为私有,外部不能随便改,函数设为公有,外部调用的时候不用传指针,直接调用就行,非常方便,而且安全。

示例 8:C++ 实现栈,感受面向对象的封装

这是 C++ 的栈实现,对比一下,是不是简洁太多了?

cpp 复制代码
class ST
{
	typedef int DataType;
private:
	DataType* _a;
	int _top;
	int _capacity;
public:
	void Init()
	{

		_a = NULL;
		_top = _capacity = 0;
	}

	void Destory()
	{
		free(_a);
		_top = _capacity = 0;
	}

	void Push(DataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
			DataType* tmp = (DataType*)realloc(_a, newcapacity * sizeof(DataType));
			if (tmp == NULL)
			{
				perror("realoc fail");
				return;
			}

			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top ] = x;
		_top++;
	}

	void Pop()
	{
		assert(_top > 0);
		--_top;
	}

	bool Empty()
	{
		return _top == 0;
	}

	int Top()
	{
		assert(_top > 0);
		return _a[_top - 1];
	}
	
};

你看,这就是面向对象的优势!我们来对比一下两者的区别:

|-------|-------------------|-------------------|
| 特性 | C 语言实现 | C++ 类实现 |
| 数据与函数 | 分离,函数要传结构体指针 | 封装在一起,不用传指针,直接调用 |
| 访问控制 | 没有,外部可以随便改结构体成员 | 有,私有成员外部不能改,数据更安全 |
| 语法便捷性 | 要写 typedef,函数要传地址 | 类名直接当类型,调用方法直接用. |
| 封装性 | 无,数据暴露在外 | 强,隐藏内部细节,只暴露接口 |

是不是一目了然?用 C++ 的类,代码更简洁,更安全,更好维护,这就是为什么我们要学面向对象的原因。

总结

今天我们学习了面向对象的入门基础:类的定义、访问限定符、类域,还有实例化、this 指针,最后我们用栈的例子,直观感受了面向对象比面向过程的优势。

这些都是类和对象的基础,一定要把例子自己敲一遍,动手实操比看十遍都有用!如果有任何不懂的地方,欢迎在评论区留言,我会一一回复!下一篇我们会讲构造函数和析构函数,带你进一步了解对象的初始化,别忘了点赞收藏关注,我是小小风,期待与你的下一次相遇!

相关推荐
浅念-1 小时前
LeetCode 模拟算法:用「还原过程」搞定编程题的入门钥匙
开发语言·c++·学习·算法·leetcode·职场和发展·模拟
澈2071 小时前
C++面向对象编程:从封装到实战
开发语言·c++
无敌昊哥战神1 小时前
【LeetCode 491】递增子序列:不能排序怎么去重?一文讲透“树层去重”魔法!
c语言·c++·python·算法·leetcode
巨量HTTP1 小时前
Python 获取动态 iframe 内容(完整解决方案)
开发语言·python
Queenie_Charlie1 小时前
关于二叉树
数据结构·c++·二叉树
王江奎2 小时前
Windows 跨平台 C/C++ 项目中的 UTF-8 路径陷阱
c++·windows·跨平台
minji...2 小时前
Linux 网络套接字编程(三)UDP服务器与客户端实现:Windows与Linux通信,新增字典翻译功能的 UDP 通信
linux·服务器·开发语言·网络·windows·算法·udp
人道领域2 小时前
【Redis实战篇】秒杀系统:一人一单高并发实战(synchronized锁实战与事务失效问题)
java·开发语言·数据库·redis·spring
艾莉丝努力练剑2 小时前
【Linux网络】计算机网络入门:网络通信——跨主机的进程间通信(IPC)与Socket编程入门
linux·运维·服务器·网络·c++·学习·计算机网络