搜“Classes(类)”这个关键词的时候,我看到了一个很有意思的现象:跳出来的内容既有Java、Python里的class,也有CSS类选择器、HTML表单类标签,还有D类功放、D类刊物、GIS地类,甚至连专利代理人转软件类要考软考哪个方向都来了。一个词,横跨编程、Web、电子、学术、地理好几个完全不同的领域,而串起它们的其实是同一件事——分类与抽象。对程序员来说,这个“类”字更是几乎避不开:不管你学的是Java、Python、C++还是C#,只要接触面向对象,第一课基本都是从类开始。这篇“Classes学习笔记总结”是我自己把散落在各个语言、各个工具里的知识重新过了一遍之后,整理出来的一个总纲。不追求教科书式的面面俱到,而是想把“类”这个概念在不同语言里的写法、在类图中的表达、在运行时里的行为,以及那些容易踩坑的报错,全部串成一条能实际用的线。适合正在学面向对象的初学者,也适合学了一些语法但始终觉得“类和对象到底怎么回事”没想明白的开发者。
1. 类的本质:一套把“分类”写进代码的方案
1.1 从日常分类到编程抽象
人类天生就爱分类。图书馆把书按中图法分成二十二个大类,超市把商品按生鲜、日化、饮料摆在不同货架,手机相册按“人物”“地点”“美食”自动整理照片——这些动作的本质,都是在提取事物的共同特征,然后归到同一个集合里。面向对象里的“类”,做的也是同一件事,只不过它把“分类”这件事变成了代码的一种组织方式。
你定义一个Dog类,本质上就是在说:所有狗都有名字、年龄这些属性,都会叫、会跑这些行为。而现实中具体的那只狗——你邻居家的柯基、楼下的小黄,就是Dog这个类的“实例”,也就是对象。类是一张图纸,对象是照着图纸盖出来的房子;类是一份菜谱,对象是照着菜谱做出来摆在桌上的那道菜。这个类比虽然老套,但确实能帮人跨过最开始的那道坎:类不是某个具体的东西,它是“一类东西”的描述模板。
1.2 三大特征如何落地:封装、继承、多态
为什么几乎所有主流语言都要搞“类”?因为它顺手解决了三个程序发展过程中的大问题:代码复用、逻辑组织、扩展维护。对应到技术上就是封装、继承、多态。
封装是“把内部细节藏起来,只留接口给别人用”。你不需要知道银行柜台后面的账务系统怎么算利息,只需要把卡递进去、说“取五百”。代码里就是private字段加public方法:属性不让人随意改,行为通过方法暴露。这个做法最大的价值不是保密,而是降低耦合——内部结构再乱,只要接口不变,外部代码就完全不用动。
继承解决的是“一堆类有共同特征,我不想重复写”。比如Animal类里已经有了吃、睡、移动这些通用行为,Dog和Cat只要继承Animal,就自动拥有了这些能力,再去补充自己特有的比如bark()或者scratch()。这是代码复用最直观的体现,但也是最容易被用歪的地方——很多人一上来就继承,实际上“组合优于继承”这条设计准则,在写了好几年代码之后回头看才真正理解。
多态是“同样的调用,不同的表现”。你写一个方法接收Animal类型的参数,调用它的sound()方法,传入Dog实例就汪汪汪,传入Cat实例就喵喵喵。调用方完全不用关心传进来的是什么具体子类,这种能力让代码的扩展性大幅提升:以后要加一个Sheep,只需要让Sheep继承Animal并实现sound(),原来所有基于Animal的代码一行都不用改。
1.3 一个“类”从哪里开始设计
新手最容易卡住的地方是:我面对一个需求,到底该怎么设计类?
我的经验是,先从需求描述里找名词和动词。名词往往是属性,动词往往是方法。比如“用户可以在商城下单购买商品”,名词有用户、订单、商品,动词有下单、购买。那么初步就能划出User、Order、Product三个类,再进一步分析它们之间的归属关系:一个用户有多个订单,一个订单包含多个商品。这种从语言层面拆解的做法没有多高深,但它能让你跨过“无从下手”的阶段。
还有一个很重要的原则:一个类只做一件事。很多初学者喜欢写一个超级类,把打印、计算、保存、发邮件全扔进去,结果类越来越大,改一个功能就要动整个类。按“单一职责”去拆,哪怕一开始拆出来的类很小,也比堆在一起好得多。
2. 同一套思想,不同语言的表达
2.1 Python的类:简洁到像伪代码
Python的类语法可能是所有主流语言里最接近自然语言的。定义一个类只需要class关键字,构造函数固定叫__init__,所有实例方法第一个参数必须是self:
class Dog: def __init__(self, name, age): self.name = name self.age = age def bark(self): return f"{self.name} says: Wang Wang!" dog = Dog("Coco", 3) print(dog.bark())这个self最容易让新手懵——为什么Python要显式写self,而Java里直接写this就行?因为Python的类方法本质上就是一个普通函数,实例调用dog.bark()时,Python会自动把dog作为第一个参数传进去。显式写self是为了让这个机制透明化,你甚至可以Dog.bark(dog)这样直接调用,效果一模一样。
还有一个容易混淆的点是类属性和实例属性。类属性定义在方法外面,属于整个类共享;实例属性挂在self上,属于每个对象独有。一个典型场景是误用可变对象做类属性,比如class Foo: items = [],所有实例会共享同一个列表,一个实例往里加东西,别的实例也看得到。踩过这个坑之后,我现在的习惯是:可变对象一律放__init__里初始化。
Python还有很多高级玩法,热搜里那条“python 类批量生成属性”其实很实用。可以用setattr批量创建属性,也可以用更现代的dataclass:
from dataclasses import dataclass @dataclass class Point: x: float y: float z: float = 0.0两行代码就把__init__、__repr__、__eq__这些模板代码全省了,在写数据类的时候能省大量时间。
另外提醒一下还在用旧版本代码的同学,distutils.version里的类已经进入弃用流程,现在官方推荐用packaging.version来比较版本号。新版项目里如果看到提示“distutils version classes are deprecated. use packaging.version instead.”,直接把from distutils.version import LooseVersion改成from packaging.version import Version就好。
2.2 Java的类:最正统的面向对象教育
如果说Python让人“舒服地写类”,Java就是“严格地教类”。Java里所有代码都必须写在类里,一个.java文件对应一个public类,甚至有一个Object类作为所有类的祖先。这个设计对教学来说特别好——你没法绕过类去写任何代码,也因此Java出身的开发者普遍对OOP的理解更扎实。
Java类里有两个细节值得展开。第一个是Object类。你创建一个普通类,即使没写extends,它也默认继承了Object,因此自动拥有toString()、equals()、hashCode()这些方法。很多人写实体类的时候懒得重写equals和hashCode,导致把对象放进HashMap或HashSet时出现“明明字段一样,却判为不同对象”的诡异问题。教训就是:只要你的类要放进集合做去重或查找,equals和hashCode就必须一起重写,而且要保持一致性。
第二个是String类的常用方法。因为字符串实在太常用了,String类的方法几乎是笔试题的常客。equals比较内容、==比较引用,这个大家都知道;substring(start, end)注意是左闭右开,indexOf找不到返回-1,split会丢弃结尾的空字符串。这些小细节不背也能写,但如果写错了排查起来特别费时间,建议直接对着文档过一遍。
Java运行时最容易遇到的热搜词之一是“找不到或无法加载主类”。这个错误的本质是JVM按类名去classpath里找对应的.class文件,但没找到。原因通常有三类:一是编译后的.class文件不在classpath里,二是public类名和文件名不一致,三是有包名的类没按包目录结构存放。排查时先看目录结构,再看java命令的-cp参数,基本能解决。
2.3 C#、C++、PHP:同一思想的不同侧重
C#的类语法跟Java很像,但它加了属性语法糖,写起来更清爽:
public class TcpClientWrapper { public string Host { get; set; } public int Port { get; set; } public async Task ConnectAsync() { // 连接逻辑 } }C#里get; set;自动生成访问器,背后其实还是私有字段加公开属性,但代码量一下少了很多。写TCP客户端类的场景热搜里也出现了,实际项目里用C#做网络通信很频繁,把连接、发送、接收、断开封装成一个类,是基本操作。
C++的类则是另一个极端:它给了你最大的控制权,也给了你最多的坑。构造函数、析构函数、拷贝构造、移动构造、虚函数、模板类——整套机制学下来,你会发现C++的类不是“模板”,而是一套管理内存和类型的完整规则体系。比如模板类实现链表,就是template <typename T> class Node,每个节点存一个T类型的值和一个Node<T>*指针。C++做强制转换也跟其他语言不一样,static_cast用于编译期可确定的类型转换,dynamic_cast用于多态类型的安全向下转换,跑虚函数场景必须用后者,否则会得到危险的裸指针。
PHP因为历史包袱,类的发展经历了好几个阶段:早期的var声明属性,后来学Java做私有字段,再到现在PHP 8的属性钩子。但PHP的类在Web开发里很灵活,写业务代码时往往不需要太重的设计,怎么简单怎么来。只是要注意别把所有逻辑全堆到方法里,否则类再多也白搭。
下面这张表可以快速对比几种语言在类实现上的差异:
| 语言 | 构造器写法 | 继承关键字 | 访问修饰符 | 对象释放方式 |
|---|---|---|---|---|
| Python | def __init__(self) | 不带extends,class B(A) | _私有、__强制私有(名称改写) | 无手动释放,GC |
| Java | 类名(...) | extends | private/default/protected/public | GC |
| C# | 类名(...) | : | private/internal/protected/public | GC |
| C++ | 类名(...) | : public 基类 | private/protected/public | 手动或RAII |
3. 类的一生:加载、实例化、类对象
3.1 类加载机制与“找不到主类”的排查
类不是天生就能用的。以Java为例,一个类从.java源文件变成能创建对象的实体,要经历编译、加载、连接、初始化、实例化好几个阶段。JVM的类加载器会按名称找到.class字节码,读取它,把它转换成内存里的Class对象,然后执行静态初始化。这整个过程一旦某一步失败,最常见的报错就是“找不到或无法加载主类”。
排查这个错,我按三步走:第一步,确认源代码编译过,target/classes目录下有对应的.class文件;第二步,确认包名和目录结构一致,com.example.Main对应的文件必须在com/example/Main.class;第三步,确认运行命令的classpath包含了target/classes目录。很多人在IDE里跑得好好的,一到命令行就报这个错,绝大多数是第三步出了问题。
Eclipse配Tomcat跑Web项目时,“找不到或无法加载主类 org.apache.catalina.startup.Bootstrap”也是高频问题。这个错误通常不是代码的问题,而是Tomcat环境配置不对——要么catalina.jar没在classpath里,要么Tomcat运行时库没加到项目的Build Path。最快的方法是重新配置一次Server Runtime Environment,并确认项目使用的JDK版本和Tomcat要求的版本一致。
还有一个诡异的场景是“IDEA里Java类突然变成咖啡图标”——之前是个蓝色的C,现在变成一杯咖啡,双击打开还是纯文本。这个其实是IDEA没把该目录识别为源代码根目录。解决办法:右键目录 → Mark Directory as → Sources Root。Maven项目里类名全部标红则通常是依赖没下载成功,检查Maven仓库里的lastUpdated文件,或者执行mvn clean install强制重新拉取依赖。
3.2 类的对象化:Python类也是对象
“类本身也是对象”这一点,是区分新手和有经验开发者的一道分水岭,尤其在Python里。Python里class关键字创建出来的类本身就是一个对象,它是type类的实例。这意味着你可以把类当作参数传递、动态创建、往类上挂属性。Python里还有元类这个概念,复杂到连很多写过几年Python的人都不常用,但理解“类也是对象”这件事,能帮你读懂框架源码里很多奇怪的写法。
热搜里有一条“python类对象”,其实就是这个话题。Python里类名()是创建实例的常用方式,但还有不少场景是直接操作类对象本身,比如前面的dataclass装饰器,本质就是接收一个类对象,动态给它添加__init__方法。
Java里对应的是Class对象和反射机制。拿到一个类的Class对象后,可以在运行时获取它的方法列表、创建实例、调用方法。框架里常见的“扫描包路径下的所有类,自动注册Bean”就是靠反射实现的。不过反射性能开销大,业务代码里能用多态解决的问题,没必要为了炫技去反射。
3.3 类型体系里的“类”与常见运行时坑
热搜里“字面量类型和数据类型的区别”看起来跟类无关,实际上它属于更底层的类型系统话题。字面量类型指“具体的值本身就是类型”,在TypeScript里很常用,比如type Status = "success" | "error";数据类型则指运行时值的类型分类。类和它们的关系是:类是数据类型的一种组织方式,而字面量类型更接近“值的精确约束”。理解层次不同,但都属于“类型”体系的一部分。
到运行时环境,Windows上经常看到“检索COM类工厂中CLSID为…的组件时失败”这种报错。它跟代码里的class关系不大,而是COM组件的注册信息出了问题——动态库没有被注册,或者注册表里的CLSID失效。排查方向是检查组件有没有用regsvr32注册、是32位还是64位版本、当前进程的位数是否匹配。这个报错很唬人,但只要把注册和位数对齐,大多能解决。
还有一类报错比如“tools.jackson 3没有这个类,import com.fasterxml.jackson.annotation.JsonFormat找不到”,属于依赖包没引对。早期版本用的是com.fasterxml.jackson前缀,后来工具类被并入tools.jackson前缀,如果你从老项目升级或者引了多个版本的Jackson,就会出现类找不到。要么统一用官方现成的jackson-databind依赖,要么代码和依赖的版本保持一致,不要混用两套前缀。
4. 画出来:UML类图与类关系
4.1 为什么要画类图
写了很多年代码之后,我越来越觉得:口头描述类之间的关系是低效的,用类图是最快的。想象你在评审会上要讲“订单模块怎么设计的”,用嘴说半个小时别人未必懂,一张类图摆出来,每个类的属性、方法、类与类之间的关系一目了然。特别是接手老项目时,没人告诉你系统怎么组织的,唯一靠谱的途径就是把源码里的类关系导出成图,然后顺着图去理解调用链。
之前热搜里看到“IDEA生成类图”和“StarUML类图怎么画”,这两个工具恰好覆盖了两种场景:一个是已经有代码,想反向生成类图;一个是要做设计,正向画图再写代码。两条路我这个月还在用,建议都掌握。
4.2 类图的基本要素和箭头含义
类图里的每个类用一个矩形表示,分三格:最上面类名,中间属性,下面是方法。属性会标访问修饰符,+表示public,-表示private,#表示protected。比如- name: String就表示私有字符串属性name。
类与类之间的关系靠不同的箭头区分,这是新手最容易看错的地方:
| 关系 | 符号画法 | 含义 | 代码体现 |
|---|---|---|---|
| 继承 | 空心三角箭头,实线,指向父类 | “是一种”关系 | class Dog extends Animal |
| 实现 | 空心三角箭头,虚线,指向接口 | “实现了某接口” | class Dog implements Pet |
| 组合 | 实心菱形头,实线,指向整体 | 整体不存在时部分也不存在 | 人的心脏,人没了心脏也没了 |
| 聚合 | 空心菱形头,实线,指向整体 | 整体与部分可分开存在 | 班级和学生,班级解散学生还在 |
| 关联 | 普通箭头,实线 | 持有一个长期引用 | 订单关联用户 |
| 依赖 | 普通箭头,虚线 | 临时使用,比如方法参数 | 类A在方法里用到类B |
这几种关系中,组合和聚合最容易混。我的判断标准是生命周期:如果整体消亡,部分必须一起消亡,就是组合;如果部分可以独立存续,就是聚合。车和发动机是组合,车报废了发动机基本没有单独使用的意义;车队和汽车是聚合,车队解散了汽车还在。
4.3 IDEA和StarUML的实操流程
IDEA里生成类图很简单:选中你要看的一个或多个类,右键 → Diagrams → Show Diagram,或者直接用快捷键Ctrl+Alt+Shift+U。生成的图可以展开字段和方法,可以右键某个类查看它继承链上的父类子类。如果项目很大,建议不要一次性画几百个类,先选择几个核心类再逐层展开,否则图会乱到没法看。
StarUML之类工具用于正向设计时,我习惯的流程是:先建一个模型文件,创建若干个类,每个类先只写类名和关键方法签名,不急着填字段;然后用箭头把关系连起来,先确定继承和实现关系,再处理组合聚合和关联;最后再补充属性和方法细节。画图的核心不是把图画得漂亮,而是逼自己在写代码前想清楚两个问题:类与类到底什么关系,谁依赖谁。这一点想清楚了,代码写起来会顺畅很多。
5. 抽象类、接口和普通类:什么时候用哪个
5.1 抽象类与普通类的核心区别
热搜里“抽象类和普通类的区别”是个极高频的提问,几乎每个学面向对象的人都会遇到。在此我集中说明一下。
普通类可以直接实例化:new Dog()没有任何问题。抽象类用abstract修饰,不能直接new,它存在的意义是当父类——把子类共有的字段和方法集中定义,同时留下一部分方法让子类必须实现。抽象方法只有声明没有方法体,任何继承它的非抽象子类都必须在代码里补全。
一个典型场景:你做一个支付系统,微信支付、支付宝支付、银行卡支付都有“统一下单”“回调验签”“退款”这些流程,但具体实现差别很大。这时候最合理的设计就是定义一个抽象类BasePayment,把“创建订单号”“写日志”这种通用逻辑直接实现了,把“发起支付请求”定义为抽象方法,让每个子类自己去实现。这样既复用了逻辑,又约束了子类——你不实现doPay(),编译直接报错。
抽象类还有一个特征是它允许非抽象成员,比如普通方法、字段、构造函数。这个特征让抽象类适合表达“是什么”的关系,因为BasePayment就是所有支付方式的共有父类,定义了它们共同的身份。
5.2 接口与抽象类:选择的关键不是语法而是语义
接口和抽象类在语法层面有重合之处,但在语义上差异非常大。接口表达的是“能做什么”的能力契约,一个类可以实现多个接口;抽象类表达的是“是什么”的身份归属,一个类只能继承一个抽象类。这个差异在真实业务里就是:你设计一个Vehicle抽象类(描述车是什么),再设计Flyable接口(描述飞的技能),FlyingCar继承Vehicle并实现Flyable,就同时具有了车的属性和飞的行为。用抽象类强行表达能力就会很别扭:你不想造一堵继承的高墙,只需要一个能力插槽。
Java 8之后接口里可以有default方法,这让接口的能力边界扩大了不少,但默认方法适合加“兜底实现”而不是替代抽象类的共享逻辑。至于普通类,它是最常见和基础的形态:可实例化、所有方法都有实现。何时选择普通类与抽象类,总结下来:如果你需要一个不能被实例化、且能约束子类行为的类型定义,就用抽象类;如果你只关心能力约定而不关心身份归属,用接口;如果所有方法都能立刻给出完整实现,也不需要考虑子类强制行为,普通类就够了。
| 类型 | 能否实例化 | 方法是否有实现 | 可继承/实现数量 | 表达语义 |
|---|---|---|---|---|
| 普通类 | 能 | 都有 | 继承1个 | 是什么、能干什么 |
| 抽象类 | 不能 | 可有可无 | 继承1个 | 是什么、约定部分行为 |
| 接口 | 不能 | 实现类必须实现(默认方法除外) | 实现多个 | 能做什么、能力契约 |
6. 同一个词的另一面:并非所有“类”都在写代码
围绕“类”这个关键词转了一圈,最后还要补上一块:在社会语境里,“类”这个字在不同行业有完全不同的含义。
Web前端里,CSS的类选择器是样式表中的基本单位。一个HTML元素可以挂多个class值,CSS里用.btn这种语法选中它们,再配合伪类选择器(:hover、:focus、:nth-child)实现交互状态。这和面向对象里的类一点关系也没有,它只是借用“分类”的概念给元素贴标签。有时候看资料会突然蹦出这两者之间的混淆信息,了解它们的区别有助于避免误解。
学术评价里的“D类刊物”、GIS里的“地类”、机器学习里的“短文本分类”、电子硬件里的“D类功放”,同样是“类”或“Class”,但各自指向完全不同的东西。D类功放指的是放大器件的工作状态(Class-D放大,用开关方式提高效率),不是编程里的类;地类是土地利用分类体系里的地类编码;短文本分类是文本分类任务,指的是给一段文本打上预定义类别。这些词都叫“类”,但互相之间没有技术上的关联。
这给我一个很实际的提醒:学习任何“类”相关的知识,先分清楚语境再动手查资料,能少走很多弯路。你在搜“Class”教程的时候,如果混进了音频功放的文档,就会觉得这个领域怎么这么难懂——那不是你的问题,是领域选错了。跨领域沟通时,“类”这个词最好加上限定语:编程的类、CSS的类、期刊的类、土地的类。
回过头来看,编程里的类之所以难学,不是语法复杂,而是它同时涉及抽象思维、语言机制、设计方法论、运行时行为四个层面,每一层都能写一本书。我自己学的时候,先把Python类写熟,再用Java把封装继承多态完整过一遍,接着用UML类图把关系画出来,最后才回头去读类加载和反射的原理——这个顺序是“实践→设计→原理”,对我这种通过实例理解概念的人来说比从理论往实践推顺畅得多。
给还在啃类概念的读者一个实际建议:不要盯着定义背“什么是类”,直接拿一个自己生活中的场景,比如“图书馆管理图书”,试着设计Book、User、Loan三个类,把它们的方法和关系写出来,再画一张类图,最后对照着写一遍代码。这个过程走完一遍,能解决你80%的困惑。
最后分享一个我近期的习惯:写完核心类之后,如果感觉脑子里的关系有点绕,我会立刻在IDE里生成类图看一眼,哪里出现奇怪的依赖入侵,马上就能发现。很多设计上的别扭,在代码里藏得很深,但一旦画成图,一眼就能看出来。