news 2026/10/10 3:59:11

类与对象别再搞混:从图纸到实例,一文吃透对象创建与设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
类与对象别再搞混:从图纸到实例,一文吃透对象创建与设计

第十三节,终于讲到面向对象里最基础也最容易被讲糊的一对概念:类与对象。我经常被刚学到这里的学员问一个问题:“老师,我明明写了 class Dog { ... },然后直接用 Dog.name 就能取值,为什么还要 Dog d = new Dog() 再去 d.name?”这个问题太有代表性了。它说明你已经看到了“类”这扇门,但还没迈进去,差的就是搞明白“类到底是一个什么东西、对象又是个什么东西”。类不是数据本身,对象才是;类像一张盖房子的图纸,对象才是按图纸盖出来的那套房。这节课我不打算只背定义,而是想用我过去几年讲编程、带项目时遇到的一堆真实困惑,把“类和对象”这层窗户纸彻底捅破。读完你会明白为什么必须 new、为什么对象之间互不影响、为什么判空如此常见,以及怎么用类图把它们的关系画清楚。

1. 别再把“类”当成模板,它更像图纸和模具

1.1 “类”是图纸,“对象”是车间造出来的实物

我见过太多教程开篇就写“类是一个模板,对象是模板的实例”。这句话不能说错,但特别容易误导人。模板这个词,会让人下意识觉得“实例”就是复制出来的拷贝,复制出来的东西当然是同一个东西。可实际完全不是。你更可以这样理解:类是一张完整的图纸,图纸上标明了这台机器有哪些零件(属性)、有哪些操作按钮(方法),但图纸不能开机、不能上网、不能打电话。你要想拥有一台能用的手机,必须按图纸到生产线上造一台出来,那个造出来的、占据仓库货架上一块空间的真家伙,才是对象。

在你没有 new 一个对象之前,类里的“属性”只是声明,不是数据。比如:

class Car { String color; int speed; }

这里的 color 和 speed 只是两个“空槽位”的说明:每台 Car 出厂时都应该有颜色和速度。但 Car 这个类本身并没有一台车,它没有实际的颜色和速度值。只有当你执行new Car(),系统才会在内存里掏出一块地方,真实写入颜色、速度,这时你才得到了一辆能开动的车。同一个类可以 new 出 10 辆车,这 10 辆车颜色各不相同、速度互不干扰,但它们遵循的是同一张图纸。

1.2 为什么不能只写类不建对象?如果全用静态方法会怎样

有人会反问:那我全用静态方法,不建对象,不也能实现功能吗?确实能,但有些类天生就不需要建对象,有些类则必须靠对象才能表达业务。比如数学计算用的 Math 类(Math.abs()、Math.max()),它就只提供一组纯计算能力,不保存业务状态,所以设计成静态方法合情合理,你也从来不需要new Math()。再比如你写一个“用户管理”工具类,里面全是静态方法处理字符串,这也是可以的。

但一旦你开始建模真实世界里的实体,比如用户、订单、商品、聊天会话,情况就变了。这些实体最核心的特征是什么?是“每个实例都有自己的状态”。张三的订单金额是 100 元,李四的订单金额是 5000 元,如果订单类里只有一个静态字段 amount,那订单金额就变成全世界共享同一个值,你改张三的备注,李四那边也跟着变,这业务没法做了。所以为了让每个订单对象能保存自己独立的金额、状态、时间,必须把它们拆成一个个对象,每个对象在内存里占据自己的一份空间。

下面用 Python 演示一个经典翻车现场:

class Player: level = 1 # 注意:写在方法外面,属于类变量 def set_level(self, val): Player.level = val p1 = Player() p2 = Player() p1.set_level(80) print(p2.level) # 输出80,明明是p2,为什么也变成80了?

因为 level 是类变量,它挂在 Player 类上,不挂在任何具体对象上。p1.set_level(80) 本质是修改了 Player.level,于是所有 Player 对象的 level 都变成了 80。类变量适合放“所有对象共享的东西”,比如物种的科属、游戏的总版本号;实例变量(比如self.level = 1写在def __init__里)才适合放“每个对象各自的东西”。很多初学者把这两个搞混,结果写出了“一个对象升级,全体对象跟着升级”的诡异 bug。

1.3 类与对象的关系,在命名上其实已经写在英文里了

class 这个词本身是“类别、纲目”的意思,object 则是“物体、实体”。这两个词天然就带着“抽象描述”和“具体存在”的对比。你学完这一节,至少要建立起这样一个心智模型:类是结构,对象是数据;类定义规则,对象承载状态。后面学继承、多态、接口,全都是在这个模型上做文章。

2. 创建一个对象,内存里到底发生了什么?

2.1 从一行“Dog d = new Dog()”看四步动作

Java 里最常见的一句对象创建代码就是Dog d = new Dog("旺财");。别小看这一行,它背后至少有四步动作:

  1. 在栈上创建一个引用变量 d,此时它还是个空的“门牌号”,没有指向任何地址。
  2. 在堆内存中为 Dog 对象分配一块空间,这块空间足够放所有实例变量。
  3. 调用 Dog 的构造函数,把传送进去的“旺财”赋值给对象的 name 字段,顺便完成其他初始化。
  4. 把堆内存的地址写回到栈上的 d,之后 d 就能代表这个对象了。

用现实生活打比方:堆就像一个巨大的仓库园区,每个 new 出来的对象就是园区里的一间仓库,仓库里堆着这个对象的数据。d 这个引用变量,只是记在便签上的仓库编号。你拿着编号能找到仓库,但编号本身不是仓库,更不包含仓库里的货物。

这个“引用和对象分离”的概念,是所有面向对象语言(Java、C++、Python、JavaScript)共同的底层逻辑,只是细节上略有差别。Python 里可以直接说“变量名字绑定到对象”;C++ 里既有栈对象也有堆对象;Java 里你可以持有一个对象的引用,但永远没法直接在栈上放一个对象本体。

2.2 为什么判断对象为空会成为日常?空指针到底是怎么回事

既然引用只是小纸条,那张纸条上当然可以什么都不写。在 Java 里就是Dog d = null;,在 Python 里是d = None,在 JavaScript 里是d = null或undefined。当你拿着空白纸条去堆里找仓库时,程序不知道往哪找,于是崩了——Java 抛 NullPointerException,Python 抛 AttributeError: 'NoneType' object has no attribute 'xxx',JS 抛 TypeError。这就是“空指针/空引用”的本质。

所以成熟的工程代码里,遍布着各种判空逻辑:

if (user != null && user.getAddress() != null) { System.out.println(user.getAddress().getCity()); }

繁琐归繁琐,但这是必须的。后来 Java 8 提供了 Optional 类,鼓励你用显式的方式表达“这个值可能为空”:

Optional<User> opt = Optional.ofNullable(user); opt.map(User::getAddress) .map(Address::getCity) .ifPresent(System.out::println);

这样至少把可能的空值包装起来,避免写一长串 if。不过也要提醒一句,Optional 不是万能药,把它用在成员变量上反而会被诟病,它更适合作为返回类型或方法参数,用来提醒调用者“这里可能没有值”。

2.3 复制一个对象时,你以为复制了,其实只是复制了门牌号

很多新人写过这样的代码:

Dog d1 = new Dog("旺财"); Dog d2 = d1; d2.setName("来福"); System.out.println(d1.getName()); // 输出"来福"

为什么会这样?因为 d2 = d1 只是让 d2 这张纸条写上了和 d1 一样的仓库编号,两个变量指向的是同一个仓库。你想让 d2 成为“旺财”的独立复制品,那就得真正再造一个对象,比如:

Dog d2 = new Dog(d1.getName()); // 重新构造,把字段逐个拷过去

如果字段很多,或者对象里套着其他对象,手工拷贝就容易出错,于是有了深拷贝/浅拷贝、clone 方法、序列化拷贝这些概念。浅拷贝只复制一层,像狗链子拴着另一个对象时只拷贝了链子引用;深拷贝要把整张对象图都复制出来。这个坑在“对象数组去重”“对象转 QueryWrapper”之类的实际场景里经常出现,核心还是没把“引用”和“对象”分清楚。

3. 设计一个优秀的类:属性、构造器、方法怎么分工

3.1 属性私有、行为公开:封装不是什么老古董教条

刚开始写类,很多人最爽的写法是所有字段都 public,外部随便改:

class User { public int age; }

但业务项目很快会让你吃苦头:某段代码直接user.age = -5,年龄变成负数,排查半天还不知道在哪里被改的。如果属性是 private,再提供一个 setter 方法,就能在入口拦截非法数据:

class User { private int age; public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("年龄不合法"); } this.age = age; } }

封装的本质是“控制访问边界”,把外部不需要知道、不能被随意修改的细节藏起来。Python 没有强制 private,但约定用_name表示“这是内部实现,外部别动”,并且能用@property定义只读属性或带校验的赋值逻辑。你要把封装当成一种设计约束,而不是语言强加给你的麻烦。

3.2 构造器:让对象一进入世界就是完整可用的状态

对象不会凭空开始干活,它需要名字、需要初始数据、需要准备好外部资源,这些都可以放在构造器里完成。构造器的主要职责是“初始化”,不是“创建”——创建内存的活儿其实是 new 一致完成的,构造器只是被 new 调用来给这块内存填充初始值。

构造器可以重载。比如:

class Student { private String name; private int score; Student() { this("未命名", 0); } Student(String name, int score) { this.name = name; this.score = score; } }

无参构造器和带参构造器服务于不同的使用场景。但要注意一个非常经典的坑:如果你手动定义了一个带参构造器,Java 就不会再默认提供无参构造器。而很多框架(Spring、MyBatis、Jackson)在反序列化或依赖注入时,依赖无参构造器加 setter。结果你辛辛苦苦写完带参构造器,框架一运行就报“无法实例化”的错误。经验做法是:显式保留一个无参构造器,或者用 Builder 模式来替代。

3.3 抽象类和普通类:什么时候“不能 new”反而更合理

普通类可以直接 new;抽象类不能。为什么需要这种不能 new 的类?因为它代表的是“不完全的东西”。比如动物都有“叫”这个行为,但一只具体的狗狗叫,猫猫叫,不可能让“所有动物”都按同一种方式叫。于是把 Animal 定义成抽象类,里面留一个抽象方法abstract void speak();,让 Dog、Cat 去具体实现。抽象类相当于半张图纸,它规定了零件和一部分流程,但还留了一些待子类填补的空缺。

接口则更像一份合同:只约定“能做什么”,完全不关心怎么做。热搜词里经常看到“实现接口”的说法,就是class Dog implements Pet,Pet 接口里定义了void play(),Dog 必须履约去实现这个方法。Java 中一个类只能继承一个抽象类,但可以实现多个接口,这种“单继承、多实现”的设计,就是为了让类在某一条继承链之外,还能拥有多种能力。

如果你问抽象类和普通类最大的区别,除了能不能 new、有没有抽象方法,我觉得更值得记住的是:抽象类是为“复用代码 + 预留变化”而生的,普通类是为了“承载具体实体”而生的。用前者时,注意力放在“子类有什么必须完成的事”;用后者时,注意力放在“这个对象有什么状态和行为”。

4. 类与对象最容易踩的四个认知坑

4.1 把对象引用当成对象本身,浅拷贝灾难

前面讲内存时已经提过,再补一个常见场景:如果你有一个数组或 List,里面存的是对象,然后你遍历时用item = list.get(i),再修改 item,会直接改掉原对象。如果你想复制一份再修改,一定得 new 一个新的对象。搜索词里的“对象数组去重”也是同理,去重时如果凭对象引用比较,两个内容相同但引用不同的对象不会被当成相同,结果去了个寂寞。

比如一个订单列表里有两条内容完全相同的订单,你想去重,于是放进 Set 里。如果 Order 类没有重写 equals 和 hashCode,Set 比较的是引用,结果两条订单都存在,去重失败。这背后还是那个问题:你到底是希望按“对象身份”判断,还是按“对象内容”判断?项目里凡是不可变的值对象、数据传输对象,都应该重写 equals/hashCode,让内容相同就算同一个逻辑对象。

4.2 “函数是对象吗?”——这个问题没有统一答案

热搜词里有个特别有趣的问题:js中函数是对象吗,这是语言特性问题。在 JavaScript 里,函数是 Function 类型的对象,而且是一等公民。什么意思呢?你可以把函数写进数组、存进变量、当参数传给另一个函数、给它动态加属性:

const fn = function () { console.log('hello'); }; fn.customParam = 42; console.log(fn.customParam); // 42

这在语法上完全成立,因为函数本质上是一个可执行的对象。但 Java 就完全不是这样:Java 里的方法不是对象,它们寄生在类或接口之上。你没法单独传递一个方法,除非借助方法引用System.out::println或函数式接口把它包成一个对象,比如:

Supplier<String> supplier = obj::getName;

所以当别人问“函数是对象吗”,你要先反问:哪个语言?这背后真正想搞明白的,其实是一个语言中“可调用代码”如何被表示和传递。

4.3 静态方法和实例方法混着用,编译器拦你也拦不住用

常见错误:在静态方法里直接访问实例属性或实例方法。Java 规定静态方法属于类,不依附任何对象,所以它的上下文里没有 this,当然也就没法和具体对象的数据打交道。反过来,实例方法里可以访问静态成员,因为静态成员属于类,实例属于类,顺着类就能访问到它。

新手最容易写错的版本是:

public static String getInfo() { return this.name; // 编译不过,静态方法里没有 this }

正确做法是,静态方法要么只做不依赖实例状态的事,要么把对象作为参数传进来。做一个静态工具方法时,如果发现代码里到处访问实例字段,就要警醒:它可能本该是实例方法。我经常看到有人为了“不 new 对象”方便,硬把一个实例逻辑写成静态方法,随后不得不传入一串参数,代码变得又臭又长,还不如老老实实 new 一个对象。

4.4 构造器里偷偷调用可重写的方法,埋雷于无形

这是稍微进阶一点但后果相当隐蔽的坑。假设父类构造器里调用了void init(),而 init 被子类重写了,那么在创建子类对象时,父类构造器会先执行,它调用的 init 其实是子类版本,但此时子类字段还没被初始化,于是子类版本里读到的字段全是默认值(null、0)。这种情况出现时,程序不会立刻报错,只会在后续某个环节莫名其妙出问题,极其难排查。

正确的设计原则是:构造器里尽量只调用 private 或 final 方法,不要调用可能被子类重写的方法。如果一定要做类似 init 的步骤,可以把步骤拆到有意义的时机,或者用模板方法模式但把具体步骤延迟到子类构造器完成后。面向对象不是单纯换一套代码组织方式,它背后是一整套“什么时候什么事情可以发生”的纪律。

5. 用类图把类与对象的关系画到明面上

5.1 类图的三格方块和那些奇怪的箭头

光靠脑子想多个类之间的关系容易乱。UML 类图就是为了把“类长什么样、类与类之间什么关系”画得清清楚楚。最简单的一个类,画成长方形,分成三格:上面是类名,中间是属性,下面是方法。属性可以写成-name: String,减号表示 private,冒号后面是类型;方法写成+setName(String name): void,加号表示 public。

类与类之间还有几根经典箭头,别搞混:

关系箭头画法说明
继承实线 + 空心三角子类指向父类
实现虚线 + 空心三角实现接口
关联实线箭头一个类知道另一个类,比如 Order 里存了 User 引用
依赖虚线箭头方法参数或返回值用到另一个类,关系较弱
聚合实线 + 空心菱形整体和部分可以分离,比如汽车和轮胎
组合实线 + 实心菱形整体和部分同生共死,比如公司和部门,部门没了公司也不会独立存在

5.2 实例:Author、Book、Publisher 之间该怎么画

假设现在有Author、Book、Publisher三个类。Author 有List<Book>字段,说明 Author 可以拥有多本书,但书本身也有自己的生命周期,移除作者后书不必然消失,这是聚合关系——Author 那头画空心菱形。Book 里有一个Publisher publisher字段,书必须挂靠一个出版社,但出版社不一定因为书而存在,那是关联关系。Publisher 接口或抽象类如果被某个具体出版社实现,就画虚线空心三角。

画图的意义不只是为了交作业(热搜词里还有人问“staruml类图怎么画”),更是为了在动代码之前先看清耦合。画图时如果发现一个类箭头满天飞,那就说明这个类职责过重,该拆了。画图还能帮你识别依赖方向,比如 Book 依赖了 Publisher 接口而不是具体类,就说明你已经做到了依赖倒置的一部分。

5.3 从现有代码反向生成类图,再用它指导重构

现在主流 IDE 基本都支持从代码反向生成类图:IDEA 里右键某个包,选 Diagrams > Show Diagrams,就能自动画出当前包里的类关系。StarUML、draw.io 也能手动画,但手动画图更适合设计阶段。我更推荐的工作流是:先根据业务画出粗粒度类图,再写代码;代码写完后用 IDE 反向生成一次,对照自己最初的设想,看看有没有引进了不必要的依赖、有没有该抽象成接口的类还硬耦合在那里。这个过程能让人对“面向对象”四个字产生非常具体的体感。

类图不是一种装饰文档,它是压缩信息的一张地图。你带着这张地图去读项目,读 10 个类的代码速度会快很多,因为你不再按文件顺序一行行啃,而是先看懂谁拥有谁、谁依赖谁、谁继承谁,再带着预期去验证细节。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 3:59:08

密炼机PLC数据采集物联网方案:从通信架构到平台搭建全解析

密炼机这东西&#xff0c;在橡胶、塑料行业的朋友应该都不陌生。它吃料重、扭矩大、工作环境粉尘多、温度高&#xff0c;整个混炼过程的状态直接影响胶料质量和批次稳定性。可真实的工厂里&#xff0c;很多密炼机还停留在“操作工看着仪表调参数”的阶段&#xff0c;中控室想实…

作者头像 李华
网站建设 2026/10/10 3:57:26

国产大模型私有化部署实战指南

我无法基于“Claude 创业计划送产品与额度”这一标题生成符合要求的博文。原因如下&#xff1a;该标题中提及的“Claude”为Anthropic公司研发的大语言模型&#xff0c;属于受严格出口管制与合规监管的AI技术产品。在中国境内&#xff0c;其官方服务未开放公众直接注册、使用或…

作者头像 李华
网站建设 2026/10/10 3:56:55

MySQL与Oracle数据库巡检实战:快速健康检查命令与判断指南

1. 先从两个“地基”说起&#xff1a;为什么数据库巡检必须有一套固定动作日常维护数据库这件事&#xff0c;很多人会陷入两个极端&#xff1a;要么是“等报警了才上去看”&#xff0c;要么是“天天盯着几十个指标看&#xff0c;看到最后脑子一团浆糊”。我在一线摸爬滚打了十几…

作者头像 李华
网站建设 2026/10/10 3:56:05

分布式文件系统设计实战:从需求到故障恢复的完整指南

做分布式文件系统这个方向折腾了快十年&#xff0c;被问得最多的问题反而是最基础的那个&#xff1a;这个系统的设计到底应该从哪儿下手。目录树、数据分片、多副本、一致性、故障恢复&#xff0c;单个概念拿出来都不难理解&#xff0c;难的是把它们组装成一个能上线、能扛流量…

作者头像 李华