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

类的属性:面向对象编程的核心状态与封装实践

类的属性:面向对象编程的核心状态与封装实践 ★ FEATURED ARTICLE
开个头先把丑话说在前头面向对象编程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里的attributePython江湖里叫“类属性”“实例属性”语义上跟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()读起来非常搞笑。正确做法是字段叫deletedgetter约定成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 ListString 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、long0float0.0fdouble0.0dchar\u0000空字符booleanfalse引用类型String、数组、自定义类null为什么会有这种差别我的理解是成员变量是对象的“档案”对象一创建这份档案就要求有完整状态哪怕没填也有个空位局部变量是方法里的“草稿纸”写什么必须你自己明确决定。理解了这层你就知道为什么很多空指针问题都出在“引用类型的属性没初始化”上——比如你声明了一个ListString 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 ListPokemon team; public Trainer(ListPokemon team) { this.team team; // 问题在这直接拿了外部的引用 } }外部传进来一个team列表之后外部代码把列表clear()了你训练师里的队伍也跟着空了。因为在Java里引用传递意味着你们指向同一块内存。正确的做法是防御性拷贝public Trainer(ListPokemon team) { this.team new ArrayList(team); } public ListPokemon getTeam() { return new ArrayList(team); // 返回副本防止外部改坏内部数据 }在构造器里拷一份进来在getter里也拷一份出去。这样内部集合和外部集合彻底隔离。这个知识点是面试高频题也是真实生产环境里数据错乱的重要根源之一。5. 实战用属性设计一个“类宝可梦”核心类5.1 需求拆解从需求到字段清单下面我们做一个简化版的“类宝可梦游戏”目标类叫Pokemon。先不看代码先把需求一条条列出来看看能推出哪些字段需求描述推导出的字段类型访问级别每只宝可梦有自己的名字nameStringprivate final宝可梦有等级等级会提升levelintprivate战斗中有当前生命值hpintprivate最大生命值由等级决定maxHpintprivate不对外开放setter宝可梦有属性水、火、草等typePokemonType枚举private每只宝可梦能学多个技能skillsListStringprivate final全游戏共创建了多少只宝可梦totalCountstatic intprivate宝可梦默认出生地区DEFAULT_REGIONstatic final Stringpublic注意这里设计上的两个取舍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 ListString 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 ListString 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控制访问、保留校验入口、降低耦合静态属性会被垃圾回收吗静态属性随类卸载而释放普通对象被回收时静态属性仍存在这些题目本身不难但能把“为什么”讲清楚的人不多。你读完这篇再去面试至少能对属性这一块做到心中有数。最后再分享一个我的实操习惯写一个新类之前先不写任何方法只把属性清单列出来并且为每个属性标注“谁可以读、谁可以改、什么时候变”。这个习惯我保持了六七年它帮我挡住了很多设计缺陷也让代码评审变得特别快。属性设计这件事看似基础但它决定了你整个类的稳固程度。把地基打牢后面那些花里胡哨的面向对象技巧才有地方施展。
阅读完成 · 觉得有帮助?
咨询建站