开个头,先把丑话说在前头:面向对象编程(OOP)这几年被讲得太玄乎了,什么六大原则、设计模式、依赖注入,一说一大套。但我在实际review代码的时候发现,很多问题根本轮不到那些高级话题出场,光是最基本的“类的成员——属性(field)”就埋了一堆雷。你要定义一个User类,里面的id、name、age就是属性;你要做一只宝可梦,名字、等级、血量也是属性。属性就是类用来保存状态的那部分数据,类里所有方法能成立,全靠这些字段先站得住脚。
这篇文章是“面向对象编程(上)”系列的第一篇,专门把“属性”这件事掰开揉碎讲清楚。[这句话删掉]不管你是刚学Java的学生,还是自学过Python想横向对比的开发者,或者是写了两三年代码但没系统梳理过属性设计原则的工程师,这篇文章都适合你。我会从“属性到底在解决什么问题”说起,一路讲到访问控制、静态属性、初始化顺序,最后用一个“类宝可梦游戏”的核心类做实战,把我在真实项目里踩过的坑、积累的经验全部倒出来。
1. 属性到底在解决什么问题
1.1 没有属性的类,只是一张空壳
面向对象编程的出发点是:把数据,和对数据的操作,绑在一起。也就是说,一个类应该同时包含“它是什么”和“它能做什么”。“它是什么”靠的就是属性,“它能做什么”靠的是方法。两者缺一不可。
举个例子,Java里有个Math类,你翻它的源码会发现,它几乎没有实例属性,只有一堆静态方法,外加PI、E两个静态常量。这种类本质上是“工具人”,它不需要保存状态,拿来就用。但业务世界里的类完全不是这样:一个订单如果没有金额、状态、创建时间这些属性,它还能叫订单吗?一个用户如果没有昵称、等级、积分,它跟一个空壳有什么区别?属性承载的是一个对象的“记忆”,没有记忆的对象是没有灵魂的。
所以判断一个类设计得好不好,我个人的经验是:先别看它的方法有多少、接口有多规范,先看它的字段列表。字段列表就能暴露这个类到底有没有想清楚自己的职责。如果你看到一个类里只有方法、没有字段,那它多半是个Utils工具类;如果字段又多又杂、互相之间没有关联,那它有“上帝类”的倾向,早晚要拆。
1.2 “属性”这个词在Java里其实有几种含义
这里必须先把概念捋清楚,不然你在技术交流或者面试的时候很容易说岔。
- field(字段/成员变量):就是直接写在类里的变量,比如
private String name;。这是“属性”最本体的含义,也是本文的重点。 - JavaBean规范里的property(属性):它在Java里有一套约定,指的是通过
getXxx()和setXxx()方法对外暴露的读写视图。一个类可能有个字段叫name,但没有getName()/setName(),那它就不算JavaBean意义上的property。Spring注入参数、MyBatis映射结果集,本质上都是基于property约定来工作的。 - 注解里的attribute:比如
@Tool(name = "xxx")里的name,也叫属性,但它其实是注解的参数,和类的字段不是一回事。 - Python里的attribute:Python江湖里叫“类属性”“实例属性”,语义上跟Java的静态字段和实例字段大致对应,但细节差别很大,后面我会专门开一节对比。
认清这几种“属性”,你就不会在讨论框架源码时把field和property混为一谈。很多初学者看到IDE里提示“cannot resolve property”,以为字段写错了,其实是JavaBean约定没配对。
1.3 属性设计的本质:定义对象的状态空间
设计属性这件事,本质上是在回答一个问题:这个对象需要记住哪些信息,才能完成自己的职责?
你可以把属性想象成一张申请表上的栏位。栏位填多了,又冗余又容易填错;栏位填少了,信息不完整,后面业务流程跑不下去。好的属性设计是“最小完备”——不多不少,刚好覆盖业务需要。
比如要设计一个图书管理系统的Book类,你至少得有:书名、作者、ISBN、馆藏数量、当前可借数量。至于“出版社联系电话”这种,大概率不该放在Book上,而该放在出版社的类里。我见过太多人图省事,把所有字段都堆到一个DTO或实体类里,结果改一个需求就要加一个字段,类越改越臃肿。记住一句话:属性是状态的载体,状态边界决定了类的边界。
2. 声明的门道:访问控制、命名与类型
2.1 一行属性声明里的四个要素
Java里声明一个实例属性,标准格式是:
[访问修饰符] [static/final] [数据类型] [属性名] = [初始值];- 访问修饰符:
public、protected、private,什么都不写就是包级私有(default)。 static:声明为类属性,属于整个类,而不是某个对象。final:声明为不可变引用,一旦赋值就不能再指向别的对象。- 数据类型:基本类型(int、double、boolean等)或引用类型(String、List、自定义类)。
- 属性名:遵循小驼峰命名,比如
userName、maxHp。
命名是这里面最容易被轻视的一环。我见过有人写private int a;、private String b;,这种做法在写算法题的时候可以忍,在业务代码里完全不能忍。属性名就是数据的身份证,一个叫a的字段,三个月后再读这段代码,没人知道它是干什么的。命名上还有个小坑要提醒一下:boolean字段不要用isXxx命名。比如你写private boolean isDeleted;,IDE自动生成的getter会变成isIsDeleted(),读起来非常搞笑。正确做法是字段叫deleted,getter约定成isDeleted()。
2.2 访问修饰符:为什么“默认private”是铁律
四个访问级别从宽到窄排列,它们是控制“谁能访问这个数据成员”的闸门:
| 修饰符 | 同类内 | 同包内 | 子类 | 任意包/任意类 |
|---|---|---|---|---|
public | 可以 | 可以 | 可以 | 可以 |
protected | 可以 | 可以 | 可以 | 不行 |
| 默认(不写) | 可以 | 可以 | 不行 | 不行 |
private | 可以 | 不行 | 不行 | 不行 |
我的原则很简单:字段默认全部private,需要暴露时再考虑放宽。很多人不理解为什么要这么麻烦,直接把属性设成public不香吗?香,但后果你自己扛。
举个例子,你写了一个User类,age字段是public int。外部代码直接来一句user.age = -5;,你一点办法都没有。业务要求年龄不能为负,但你现在根本没地方做校验。如果字段是private,外部只能通过setAge(int age)方法修改,你完全可以在setter里加判断:
public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("年龄不合法: " + age); } this.age = age; }这就是封装的价值:你对数据的所有修改,都强制经过你的规则。这也是“面向对象编程”里“访问data成员”热搜词背后真正要表达的东西——成员怎么暴露、怎么被访问,主动权必须握在类自己手里。
2.3 属性和JavaBean property的关系别搞混
前面说了,字段是物理存在,property是逻辑约定。判断一个类有没有某个property,要看它有没有对应的getter/setter。比如:
public class Book { private String title; // 这个是field public String getTitle() { return title; } // 有了这个,title才算property }框架里常说的“属性注入”,比如Spring的@Value("${app.name}"),走的就是这个约定。IDE的“Generate Getter and Setter”功能,本质上是帮你把字段“翻译”成property。理解这一点之后,你在看Spring、MyBatis源码的时候会顺畅很多——它们通过反射拿到的往往是getTitle()方法名推导出的title属性名,而不是直接读字段。
3. 实例属性、静态属性、常量:三类特性对比与选型
3.1 实例属性:一个对象一份数据
实例属性是最常见的属性形态,不加static即可。它的生命周期和对象绑定:对象在堆内存里创建,实例属性也跟着对象走;对象被回收,属性数据一起消失。每个对象都持有自己独立的一份副本,互相不干扰。
比如宝可梦游戏里,皮卡丘的等级是5,杰尼龟的等级是10,它们各自拥有独立的level字段。如果level被误声明成static int,那全世界所有宝可梦共享一个等级,你给皮卡丘升了一级,杰尼龟跟着变6级,这游戏就废了。所以判断用不用实例属性,一句话:只要数据是“这个对象自己的”,就是实例属性。
3.2 静态属性:属于类而不是属于对象
static修饰的属性属于类本身,在类加载的时候分配内存,所有实例共享同一份。静态属性适合放三类东西:类级别的计数器、全局配置参数、共享资源。
以宝可梦为例,我想要统计“一共创建了多少只宝可梦”,这个数字不该属于任何一只具体的宝可梦,而属于整个游戏系统,那就用静态属性:
public class Pokemon { private static int totalCount = 0; // 所有实例共享 public Pokemon() { totalCount++; } public static int getTotalCount() { return totalCount; } }这里有个大坑必须警告:共享可变状态是并发问题的温床。如果一个静态集合字段是public static List,任何类都能往里面加东西,多线程环境下还会出现各种诡异的并发修改异常。我见过一个线上事故,就是有人为了图方便,把配置缓存放在public static HashMap里,结果被某个调用方顺手清了,整个服务配置瞬间丢失。静态属性可以用,但尽量做到要么不可变,要么完全由类内部掌控。
3.3 常量:static final的组合拳
常量是静态属性的一种特化形态,用static final联合修饰。命名约定是全部大写加下划线,比如DEFAULT_REGION、MAX_LEVEL。Java标准库里有大量这种例子,Math.PI就是个public static final double。
注意一个容易踩的坑:final修饰引用类型时,只保证“引用不能变”,不保证“对象内容不能变”。举个例子:
public static final List<String> DEFAULT_SKILLS = new ArrayList<>();这个DEFAULT_SKILLS虽然被final钉死了,但外部拿到这个引用后照样可以add和clear,数据一样会被改坏。如果你真需要一个不可变集合,正确的写法是List.of(...)或者Collections.unmodifiableList(...)。常量的本质是“值恒定”,不是“引用恒定”。
3.4 Python类属性与Java静态属性的横向对比
热词里有“python类属性、类方法和静态方法”,这里顺便做个对比。Python里:
class Pokemon: default_region = "关都" # 类属性,相当于Java的static字段 _total_count = 0 # 约定私有,但没有真正的private def __init__(self, name, level): self.name = name # 实例属性,必须通过self动态赋值 self.level = level Pokemon._total_count += 1Python的类属性在语义上等于Java的静态字段,但也有微妙差异:Python的类属性可以通过实例访问,而且你在实例上给同名的属性赋值时,不会修改类属性,而是给实例创建了一个新的实例属性,把类属性“遮蔽”掉。这个行为曾让很多Java转Python的人栽跟头。
Java这边就明快得多:static就是类的,实例属性就是实例的,没有动态创建属性这回事。所以从设计严谨性讲,Java更符合“先声明后使用”的思路;从灵活性讲,Python更随意。做工程我更倾向Java这种“把属性边界画清楚”的风格,因为代码的可预测性高,不容易被运行时动态属性搞得晕头转向。
4. 属性初始化的完整链路:从默认值到构造器
4.1 默认值:Java给每个成员变量发的“初始工资”
Java有一个很多人没细想过但很重要的行为:成员变量即使你不显式赋值,也有默认值。而方法里的局部变量没有默认值,不初始化直接使用会编译报错。
| 数据类型 | 默认值 |
|---|---|
int、short、byte、long | 0 |
float | 0.0f |
double | 0.0d |
char | '\u0000'(空字符) |
boolean | false |
| 引用类型(String、数组、自定义类) | null |
为什么会有这种差别?我的理解是:成员变量是对象的“档案”,对象一创建,这份档案就要求有完整状态,哪怕没填也有个空位;局部变量是方法里的“草稿纸”,写什么必须你自己明确决定。理解了这层,你就知道为什么很多空指针问题都出在“引用类型的属性没初始化”上——比如你声明了一个List<String> skills;却没在构造器里new ArrayList<>(),那skills就是null,一调用skills.add()就崩。
4.2 初始化顺序:先静态,再实例,最后构造器
很多人知道“构造器里给属性赋值”,但不知道在构造器执行之前,属性就已经走过好几道程序了。完整的顺序是:
- 静态属性声明初始化、静态代码块(按代码书写顺序执行,只在类加载时执行一次)
- 实例属性声明初始化、实例代码块(每创建一个对象都执行)
- 构造器
来看一个直观的演示:
public class InitOrderDemo { private static int staticField = initStaticField(); static { System.out.println("1. 静态代码块"); } private int instanceField = initInstanceField(); { System.out.println("2. 实例代码块"); } public InitOrderDemo() { System.out.println("3. 构造器"); } private static int initStaticField() { System.out.println("0. 静态属性初始化"); return 1; } private int initInstanceField() { System.out.println("A. 实例属性初始化"); return 2; } public static void main(String[] args) { new InitOrderDemo(); new InitOrderDemo(); } }执行结果会告诉你:静态部分只打印一次,实例部分每次创建对象都打印,而且严格排在构造器之前。这个顺序不是面试官拿来刁难人的冷知识,它直接解释了一个经典问题:为什么在构造器里调用一个被子类重写的方法,经常拿到null字段。
因为在Java里,父类构造器先执行,而子类的实例属性还没来得及初始化。如果父类构造器里调用了子类重写的getInfo()方法,而getInfo()要访问子类属性,此时子类属性还是默认值。这种bug极其隐蔽,排查起来非常痛苦。我的建议是:构造器里只做属性赋值,绝不调用可被重写的方法。
4.3 构造器赋值与this的救场
给属性赋值最常见的入口就是构造器。新手最容易犯的错是参数名和属性名撞车:
public class Trainer { private String name; public Trainer(String name) { name = name; // 左右都是参数,属性根本没赋值 } }这里name = name;左右两边都是方法参数,this.name纹丝不动。正确写法必须加this关键字:
public Trainer(String name) { this.name = name; }this就是“当前对象”的代词,this.name明确告诉编译器“我要访问的是实例属性name,不是参数name”。这个细节我几乎每次带新人都会强调一次,因为太容易出了,而且出了之后很困扰:字段不是有默认值吗?为什么我传进来的名字没用上?
另外,构造器重载时可以用this(...)复用另一个构造器,但这行调用必须是构造器第一行。比如:
public Pokemon(String name) { this(name, 1); // 调用带等级参数的构造器 }这种链式初始化能避免代码重复,也保证所有属性都走同一套初始化逻辑。
4.4 可变属性与防御性拷贝:必懂的自我保护
属性类型如果是不可变类(String、Integer、LocalDate),直接赋值没问题。但如果是List、Map、Date这类可变对象,就要多留一个心眼。
比如训练师类:
public class Trainer { private final List<Pokemon> team; public Trainer(List<Pokemon> team) { this.team = team; // 问题在这:直接拿了外部的引用 } }外部传进来一个team列表,之后外部代码把列表clear()了,你训练师里的队伍也跟着空了。因为在Java里,引用传递意味着你们指向同一块内存。正确的做法是防御性拷贝:
public Trainer(List<Pokemon> team) { this.team = new ArrayList<>(team); } public List<Pokemon> getTeam() { return new ArrayList<>(team); // 返回副本,防止外部改坏内部数据 }在构造器里拷一份进来,在getter里也拷一份出去。这样内部集合和外部集合彻底隔离。这个知识点是面试高频题,也是真实生产环境里数据错乱的重要根源之一。
5. 实战:用属性设计一个“类宝可梦”核心类
5.1 需求拆解:从需求到字段清单
下面我们做一个简化版的“类宝可梦游戏”,目标类叫Pokemon。先不看代码,先把需求一条条列出来,看看能推出哪些字段:
| 需求描述 | 推导出的字段 | 类型 | 访问级别 |
|---|---|---|---|
| 每只宝可梦有自己的名字 | name | String | private final |
| 宝可梦有等级,等级会提升 | level | int | private |
| 战斗中有当前生命值 | hp | int | private |
| 最大生命值由等级决定 | maxHp | int | private,不对外开放setter |
| 宝可梦有属性(水、火、草等) | type | PokemonType(枚举) | private |
| 每只宝可梦能学多个技能 | skills | List<String> | private final |
| 全游戏共创建了多少只宝可梦 | totalCount | static int | private |
| 宝可梦默认出生地区 | DEFAULT_REGION | static final String | public |
注意这里设计上的两个取舍:
name和skills我用final,因为一只宝可梦被创建后,名字和技能表不应该被“换掉”,只能修改技能内容。final表达了“引用不可变”的意图,天然地阻止外部重新赋值。maxHp我纠结了一下,到底是存字段还是动态计算。最后我选择存字段,但只在构造器里计算一次。因为游戏中可能还有临时Buff影响最大血量,存字段方便扩展。但如果你有“升级必须同步刷新maxHp”的需求,那就得在升级方法里同时更新,否则hp/maxHp会出现不一致——这种**“派生数据存字段”的维护成本,是属性设计时必须预期的代价**。
5.2 代码实现:一个完整的Pokemon类
import java.util.ArrayList; import java.util.List; public class Pokemon { public static final String DEFAULT_REGION = "关都"; private static int totalCount = 0; private final String name; private final PokemonType type; private final List<String> skills; private int level; private int maxHp; private int hp; public Pokemon(String name, PokemonType type, int level) { this.name = name; this.type = type; this.skills = new ArrayList<>(); this.level = level <= 0 ? 1 : level; this.maxHp = 100 + this.level * 10; this.hp = this.maxHp; totalCount++; } public String getName() { return name; } public PokemonType getType() { return type; } public int getLevel() { return level; } public int getMaxHp() { return maxHp; } public int getHp() { return hp; } public List<String> getSkills() { return new ArrayList<>(skills); } public void learnSkill(String skill) { if (skill == null || skill.isBlank()) { throw new IllegalArgumentException("技能名不能为空"); } this.skills.add(skill); } public void takeDamage(int damage) { this.hp = Math.max(0, hp - damage); } public void heal(int amount) { this.hp = Math.min(maxHp, hp + amount); } public void levelUp() { this.level++; this.maxHp += 10; this.hp = this.maxHp; // 升级回满血 } public static int getTotalCount() { return totalCount; } }这个类我刻意把“读”和“改”分开设计:name、type只有getter没有setter,外部永远没法改;level和hp也不直接暴露setter,而是通过levelUp()、takeDamage()、heal()这几个业务方法去修改。这样就把hp永远限制在[0, maxHp]区间内,外部即使想乱来也没有入口。这正是属性封装要达成的效果:数据可以读,改只能走业务的闸门。
5.3 属性设计的三张检查清单
写完之后,我习惯用三张清单自查,你可以直接抄走:
- 第一张:每个属性都问一遍。命名能否一眼看懂?类型是否选得恰当?是否该加
final?访问级别是否最小暴露?初始值是否合理?如果某个属性回答不上来“它为什么存在”,删掉它。 - 第二张:可修改性检查。不是所有属性都需要setter。属性越容易被改,类的不变量就越容易失守。如果一个字段可以在多个地方被修改,请把所有修改点集中到少数几个方法里。
- 第三张:与UML类图对照。UML类图里属性的标准写法是
- name: String、+ DEFAULT_REGION: String,减号代表private、加号代表public,#代表protected。画类图的过程其实就是属性设计的复审过程。如果类图上一眼看去字段一堆、方法寥寥,那这个类八成是“数据袋子”,要把行为也抽进来。
这三个检查清单我每次创建实体类、领域类都会过一遍,虽然多花两分钟,但后面维护阶段省下的时间远不止两分钟。
6. 常见问题与排查实录
6.1 字段总是null或0,先查初始化顺序
现象:对象创建完成后,某个引用类型属性一直是null,或者数字一直为0。排查思路按概率排:
- 该属性压根没在构造器里赋值,只是声明了
private List skills;,然后忘了初始化。 - 属性在实例代码块和构造器里都被赋值,构造器还把实例代码块的值覆盖掉了。
- 继承场景下,父类构造器提早调用了某个方法,此时子类属性还没初始化。
我的建议是:所有属性在构造器里集中赋值,不要出现“一部分靠声明初始化、一部分靠构造器、一部分靠setter”这种三种方式混用的局面,否则你根本记不住哪个生效。
6.2 外部直接改属性,数据全乱套
现象:user.age变成负数、order.status被改成不存在的值,这类问题九成是因为属性被声明成了public。修复方案不复杂:属性改private,新增getter和setter,在setter里做合法性校验。如果这个改动波及面太大,初期可以先用IDE的“封装字段(Encapsulate Fields)”重构功能批量搞定,它会自动生成访问方法并替换外部调用点。
6.3 静态集合在多线程环境下数据错乱
现象:同一个静态HashMap被多个线程读写,出现ConcurrentModificationException或数据丢失。根因是共享可变状态没有做并发控制。解决方案有三个梯度:
- 把静态集合替换成并发容器,如
ConcurrentHashMap。 - 如果集合只在类加载时初始化、之后不再修改,用不可变集合并保持
static final。 - 如果集合必须动态变更,把读写操作收拢到静态方法里并加锁。
请牢记:静态属性不是“公共变量”的合法理由。静态属性应该尽量是不可变的配置或常量,而不是全局共享的垃圾桶。
6.4 参数名和属性同名,赋值却无效
现象:构造器里name = name;执行完,对象的name还是null。原因就是this丢失。排查起来很快,搜一下构造器里有没有“自己等于自己”的赋值。这里再补充一个小技巧:你可以在IDE里开启“参数名与字段名不同的配色提示”,或者直接养成习惯——构造器参数一律用和属性相同的名字,然后显式写this.xxx = xxx,这样既清晰又不容易错。
6.5 高频面试题速查表
顺带把属性相关的高频面试题整理成一个速查表,面试前可以扫一眼:
| 面试问题 | 回答要点 |
|---|---|
| 成员变量和局部变量的区别 | 位置、默认值、生命周期、修饰符 |
| 实例属性和静态属性的区别 | 属于对象还是属于类、内存分配时机、共享与否 |
| final修饰属性有什么效果 | 引用不可变、必须显式赋值或构造器赋值 |
| 什么是封装,为什么属性要private | 控制访问、保留校验入口、降低耦合 |
| 静态属性会被垃圾回收吗 | 静态属性随类卸载而释放,普通对象被回收时静态属性仍存在 |
这些题目本身不难,但能把“为什么”讲清楚的人不多。你读完这篇再去面试,至少能对属性这一块做到心中有数。
最后再分享一个我的实操习惯:写一个新类之前,先不写任何方法,只把属性清单列出来,并且为每个属性标注“谁可以读、谁可以改、什么时候变”。这个习惯我保持了六七年,它帮我挡住了很多设计缺陷,也让代码评审变得特别快。属性设计这件事,看似基础,但它决定了你整个类的稳固程度。把地基打牢,后面那些花里胡哨的面向对象技巧才有地方施展。