首页 / 资讯中心 / 文章详情

C++入门必修:用“图纸与实物”理解类与对象

C++入门必修:用“图纸与实物”理解类与对象 ★ FEATURED ARTICLE
网上关于C入门的话题热度一直不低但真正劝退很多人的往往不是语法本身而是“类和对象”这道坎。我见过不少初学者普通变量、函数、循环都写得比较熟练一到class就懵类到底是干什么的对象又是什么为什么定义一个类不占空间创建一个对象反而占这些问题问多了之后我发现大家缺的不是知识点而是一个能说服自己的心智模型。“初始C”这个标题挺有意思它既可以理解成“初识C”也可以理解成“把C的基础状态初始化一遍”。不管怎么读你要做的第一件事都是把心态放平C不是靠背语法学会的是靠一遍遍建立模型、再拿代码验证学会的。这篇我会从“为什么要用C入门”开始讲接着写一写第一个能跑的程序然后集中火力把类和对象初识阶段的几个核心问题讲透最后用一个完整的小例子把前面所有内容串起来。1. 为什么入门编程我建议从C开始1.1 C是一门“三合一”语言这反而是好事C很特殊的一点是它同时支持三种不同的编程范式。你可以完全用面向过程的方式写把一个个函数排开程序按顺序执行也可以用面向对象的方式写让数据和行为都封装在类里还可以用泛型的方式写模板让同一份代码适配多种类型。这三种风格在C里不是互斥的而是可以按需混用。很多初学者一看到“C复杂”就紧张但换个角度想正因为它是多范式的你学的时候不需要一次全部学会。入门阶段完全可以先用面向过程的思路写程序等类与对象初识完成、能建类、能建对象之后再慢慢切换成面向对象的风格。这样反而给了你缓冲的空间。1.2 C能让你看见“变量”背后到底发生了什么大多数高级语言把内存隐藏得很好初学者定义一个变量就完事了但不知道这个变量存在哪、什么时候消失。C不一样它逼你稍微多想一点变量是内存里的一块区域函数调用会压栈对象离开作用域会被销毁。这种“稍稍贴近底层”的体验对后续学习非常有帮助。你之后理解引用、指针、数组、对象生命周期都是在给现在建立的这块地基添砖加瓦。如果你先学了其他语言再回来学C常常会觉得“原来变量真的是个地址的别名”但如果直接从C入门这些就是顺理成章的事情。这里也想提醒一句入门阶段的C并不需要你懂内存地址、不需要你碰指针。先把对象当作一个“会自己做事的变量”来理解就行等类和对象基础扎实了再回头研究它内部的内存细节才不会被打乱节奏。1.3 初学阶段最重要的心智模型类是一张图纸对象是按图纸造出的实物在还没有写任何代码之前先记住下面这个类比它能帮你少走很多弯路。类class是设计图纸。它描述了数据该长什么样、有哪些行为但不代表一个具体的、活生生的东西。对象object是按照图纸造出来的实物。一个类可以造出很多个对象这些对象各自独立、互不干扰。后面要讲的构造函数、析构函数、成员函数全部都是围绕“怎么把图纸变成实物”“实物被销毁时怎么办”展开的。先把这个最朴素的模型放在脑子里再去看代码你会发现每一段代码都能对应到图纸或实物中的一个动作。2. 第一个C程序先跑起来再谈道理2.1 环境准备不要在IDE选择上过于纠结写C之前需要两样东西一个支持C11以上标准的编译器和一个让你编辑代码舒服的编辑器。只要能让“编辑–编译–运行”这个循环顺畅什么组合都行不需要一开始就追求功能复杂的工程管理器。入门阶段我的建议是先不要创建一堆工程文件直接开一个代码文件编译运行。这样能把注意力集中在C语言本身而不是花一下午研究“为什么新建项目时没有模板”。后面遇到需要多人协作、需要配置构建系统的项目时再回头研究工程化也不迟。2.2 逐行拆解一个最小程序先看这段最经典的代码#include iostream int main() { std::cout Hello, C std::endl; return 0; }第一行#include iostream是预处理指令意思是“把标准输入输出相关的声明包含进来”。之后你才能用std::cout输出文字。这个机制不是C语法本身的运行逻辑而是编译过程的一个环节你现在只需要记住要输入输出就得包含头文件。然后是main函数。任何C程序都是从main开始执行的它有且只有一个。程序运行结束后return 0;告诉系统“我正常退出了”。std::cout是一个标准输出流对象运算符把右边的内容送入这个流std::endl表示换行并刷新缓冲区。看到std::时不要慌它表示“这些东西定义在标准命名空间std里”是为了避免名字冲突。2.3 从读代码到写代码变量和基本输入输出只读不写没有意义。下面这个程序展示了变量、输入和输出是怎么配合的#include iostream int main() { int age 0; std::cout 请输入一个整数: ; std::cin age; std::cout 你输入的数是: age std::endl; return 0; }这里有一个核心观念要建立int age 0;并不只是“创建一个叫age的变量”它是在内存里给这个变量一块空间并把它初始化成0。std::cin age则把用户输入的数放进这块空间里。理解这一点是很必要的。因为后面你创建Student stu(S001, 88.5);时本质上也是在内存里创建一个叫做stu的对象并给它的内部数据赋值。对象和普通变量在内存模型上是一脉相承的。3. 类和对象把数据和操作装进同一个“模板”3.1 从struct到class为什么要把数据和操作放在一起C里有一种老的语法叫struct它可以把多个不同类型的数据打包成一个整体比如一个学生有学号、姓名、成绩。这样做的确比散落各处的变量清晰但还不够。原因是现实中的数据往往伴随操作。你有了学生数据就想要打印它、修改成绩、判断是否及格。如果这些操作全都写成独立函数每次都要把学生对象传进去代码很快就会变得散乱。类class解决的正是这个问题。它既包含数据也包含操作这些数据的函数。数据叫“成员变量”函数叫“成员函数”。这样的好处是数据和行为被绑定在一起代码的可读性明显提高。看下面这段class Student { public: std::string id; double score; };这就是最简版本的类它有一个学号、一个成绩。但注意写出这个类之后程序里并没有任何学生数据存在它只是图纸。要制造出真正的学生需要创建对象#include iostream #include string class Student { public: std::string id; double score; }; int main() { Student stu; stu.id S001; stu.score 88.5; std::cout 学号: stu.id , 成绩: stu.score std::endl; return 0; }Student stu;创建了一个对象然后通过.访问它的成员变量。这是类与对象初识阶段最简单的用法务必亲手敲一遍。3.2 class里的public和private不是摆设是边界很多初学者对public和private感到困惑反正都是成员为什么不全部公开用手机类比会更容易理解。手机外壳上有听筒、扬声器、按键这些是用户能用的“公共接口”内部的主板、电池、线路则是“私有部分”。厂商不会让你直接碰主板因为那样很容易损坏设备。private成员就是对象内部不能随便动的部分外部代码想修改它必须通过对象提供的公共接口。这个设计在程序里非常实用。比如银行账户的余额如果直接公开外部代码轻而易举就能把余额改成负数如果用private包起来再通过deposit这样的公共函数修改函数里就能加上“余额不能为负”的校验。class BankAccount { private: double balance; public: void deposit(double amount) { if (amount 0) { balance amount; } } double getBalance() { return balance; } };外部代码只能调用deposit和getBalance无法直接改balance。这种把内部细节藏起来、只暴露必要接口的做法在面向对象里叫“封装”。你现在知道它的名字就够了等写得项目越来越大会越来越感受到它的价值。3.3 类不占空间对象才占空间我再强调一次这个模型class Student { ... };只是在描述学生应该有哪些数据和行为它本身不占运行内存。真正占内存的是Student stu;这个对象因为它在运行时会真的给学号、成绩分配空间。可以同时创建多个对象它们之间完全独立Student s1; Student s2;s1和s2用的是同一张图纸但各自拥有自己的id和score。给s1改成绩不会影响s2。这个“多对象独立”的概念看起来简单却是后续学习数组、容器、指针时的重要基础因为你会经常把一堆对象放在一起操作。4. 对象的一生构造函数、析构函数与this指针4.1 构造函数让对象一出生就是可用的如果把对象比作一个新生婴儿构造函数就是产房里的护士负责在孩子出生时做好登记姓名、学号、初始成绩一次性搞定。构造函数要满足三个特征名字和类名相同、不写返回类型连void也不能写、在创建对象时自动调用。看这个例子class Student { private: std::string id; double score; public: Student(std::string initId, double initScore) { id initId; score initScore; } void show() { std::cout 学号: id , 成绩: score std::endl; } }; int main() { Student stu(S001, 88.5); stu.show(); return 0; }这里Student stu(S001, 88.5);会调用构造函数把学号初始化为S001成绩初始化为88.5。对象创建完成时就已经是“完整可用”的状态不需要再像上一节那样手动一行行赋值。构造函数也可以重载也就是一个类里可以写多个构造函数参数不同即可。比如可以加一个无参构造函数允许先创建一个空学生对象后面再单独赋值。4.2 析构函数对象离场时的“清洁工”有出生就有离场。当一个对象离开了它的作用域比如函数执行结束、局部对象要被销毁时析构函数会立刻被自动调用。它的名字也很直白在类名前面加一个波浪号~。#include iostream class Demo { public: Demo() { std::cout 构造了 std::endl; } ~Demo() { std::cout 析构了 std::endl; } }; int main() { Demo d; std::cout main函数执行中 std::endl; return 0; }这段程序的输出顺序是构造了 main函数执行中 析构了你不需要手动调用析构函数它会在对象生命周期结束时自动触发。现在这段代码里对象没有申请额外资源所以析构函数看起来只是打了个招呼。但如果对象内部用new申请过堆内存析构函数里就必须用delete释放否则会内存泄漏。4.3 this指针成员函数怎么知道自己在操作谁一个类可以有很多对象但成员函数只有一份代码。问题来了调用s1.show()和调用s2.show()时函数里的id到底是谁的答案就是this指针。每个成员函数在被调用时都会收到一个隐藏参数指向“当前正在操作的那个对象”。你可以把this理解为一张贴在对象身上的身份牌函数执行时它翻开这张身份牌就知道该读写谁的数据。最常见的使用场景是参数名和成员变量名重叠时。典型写法是class Student { private: std::string id; public: Student(std::string id) { this-id id; } };这里的std::string id是构造函数参数this-id是成员变量。this-id id;的意思是“把传入的id赋值给成员变量id”。如果不加this-id id;会让两个同名参数自己赋自己成员变量根本不会被赋值这是初学阶段特别容易出现的隐蔽bug。4.4 声明和实现分离类外定义成员函数早期写代码可以把成员函数直接写在类里。但在更规范的项目里类更像一个接口清单先列出这个类能干什么再在类外面具体实现。class Student { private: std::string id; double score; public: void show(); }; void Student::show() { std::cout 学号: id , 成绩: score std::endl; }注意void Student::show()前面的Student::它告诉编译器“这个show函数属于Student类”。如果没有这一行编译器会把它当成一个普通的全局函数而普通全局函数是访问不到id和score的。这种区分很容易被忽略却也是理解“封装边界”的很好补充。5. 综合示例用类和对象写一个成绩管理小工具5.1 为什么选这个需求知识点光看不练很难内化。我选了一个非常贴近课程作业的场景记录若干名学生的学号和成绩支持修改成绩、打印信息。这个需求不复杂但能用到构造函数、私有成员、成员函数、对象数组和循环正好把前面几章的内容全部串起来。在设计这个类时我要保持接口清晰外部代码不直接访问学号和成绩而是通过setScore修改、通过print读取。这样既有封装的意义又能展示“对象数组”这种常见用法。5.2 完整代码和运行效果#include iostream #include string class Student { private: std::string id; double score; public: Student(std::string initId, double initScore) { id initId; score initScore; } void setScore(double newScore) { score newScore; } void print() { std::cout 学号: id , 成绩: score std::endl; } }; int main() { Student students[2] { Student(S001, 88.5), Student(S002, 76.0) }; students[0].print(); students[1].print(); std::cout 修改第二个学生成绩 std::endl; students[1].setScore(95.0); students[1].print(); return 0; }程序输出为学号: S001, 成绩: 88.5 学号: S002, 成绩: 76 修改第二个学生成绩 学号: S002, 成绩: 95可以看到students是一个对象数组数组里的每个元素都是一个独立的Student对象。通过下标访问它再通过.调用成员函数整个逻辑非常直观。5.3 这个示例里的几个观察点第一个观察点是构造函数自动初始化。定义对象数组时Student(S001, 88.5)会主动构造每个对象所以后面使用时不需要再单独赋值。第二个观察点是setScore(95.0)演示了public接口如何修改私有数据。外部代码不知道score内部存的是什么、怎么校验规则它只知道“按这个方法传参就行”。第三个观察点是如果你把print()改成带const的写法比如void print() const会进一步增强代码的安全性。const提醒编译器这个成员函数不会修改对象的数据。入门阶段可以先不看它但遇到“常量对象不能调用非const成员函数”的报错时就能回想到这里。6. 初学者最容易踩的5个坑和对应解法6.1 struct和class到底有什么区别这个问题几乎每个班都会有人问。从语法上看两者几乎一样都能放成员变量和成员函数。唯一的默认区别是访问权限struct默认publicclass默认private。struct Point { int x; int y; }; class Point2 { int x; int y; };Point的对象可以直接p.x 3;因为默认公有但Point2的对象直接写p2.x 3;会编译失败因为默认私有。所以当你用class定义类时一定要记得写public:否则你定义的成员别人全都访问不到。我建议初学者暂时统一用class并且主动把public和private写清楚。struct在C里更适合用来表示纯数据结构等你对访问控制有了直觉再根据场景选择。6.2 构造函数不能写返回类型这几乎是新手报错的重灾区。构造函数是一种特殊函数它的任务是在对象创建时完成初始化因此语法上不允许写返回类型连void也不行。一旦写了void Student::Student(...)编译器会认为你在定义一个普通函数立刻报错。正确的写法只有Student(std::string initId, double initScore) { id initId; score initScore; }记住一句话构造函数和析构函数是C中仅有的两种“不需要返回类型”的函数。写代码时多留个心眼能帮你省下不少编译时间。6.3 对象传参时的拷贝问题如果你把对象直接作为函数参数传过去会发生对象拷贝。初学者常忽略这一点觉得“传对象进去对象就还是那个对象”。但实际上形参是用实参复制出来的另一个对象。void show(Student s) { s.print(); } int main() { Student stu(S001, 88.5); show(stu); return 0; }这里的Student s是stu的一个拷贝show函数结束时s会被析构随后stu也会在main结束时析构。如果类里有动态申请的内存这种拷贝很容易造成内存重复释放。入门阶段先用“值传递 复制一份”这个观念提醒自己等到学习引用时再通过传引用来避免拷贝。6.4 using namespace std到底能不能用很多教程喜欢写using namespace std;这样cout不用加std::看起来简洁很多。但它的代价是把std里的所有名字都引入了当前作用域很容易和自定义名字发生冲突。比如你自己定义一个函数叫count而std里恰好也有类似的命名代码多了之后就会莫名其妙出现编译错误。我的建议是初学练习时可以暂时用但要清楚它不是必须的等代码规模变大尽量改成std::cout或者只引入你需要的那一个using std::cout; using std::endl;这样既简洁又不会把所有名字都拖进来。6.5 没有默认构造函数时对象数组可能编不过如果你写了一个带参数的构造函数但没写无参构造函数那么下面这段代码会报错Student arr[3]; // 如果Student没有默认构造函数编译失败原因很简单创建对象数组时程序要为每个元素都构造一个对象可它不知道用什么参数来构造。解决办法有三种给Student增加一个无参构造函数或者在数组初始化时逐个提供参数或者改用能动态扩容的容器等学到标准库的vector之后会方便很多。这个坑理解起来并不难但它能提醒你构造函数不只是语法它还和对象生命周期、数组、动态分配等机制紧密相关。我在实际教学中发现能把构造函数、析构函数、对象数组这几个点串起来的人通常在后面学指针、动态内存时也格外顺利。类和对象初识阶段不需要把每个细节都抠到极致但一定要亲手写几个有着多个成员变量和成员函数的小类观察它们在main函数里的创建、调用和销毁过程。这种“先建立模型、再用代码验证”的学习方法比单纯背关键字的含义有用得多。
阅读完成 · 觉得有帮助?
咨询建站