面向对象设计:类与实例的边界如何界定?以Java为例
这个问题戳中了很多OOP初学者(甚至有经验的开发者)都会纠结的点——什么时候该拆分类/异常,什么时候该用属性来区分。咱们一个个来聊:
首先,精准的异常捕获和处理是核心原因。想象一下,如果所有IO异常都只是IOException的实例,你要捕获“文件不存在”的场景,就得先catch IOException,然后检查cause或者异常消息,这不仅麻烦,还容易出错(比如消息格式变了判断就失效)。而有了FileNotFoundException,你可以直接写:
try { // 读取文件 } catch (FileNotFoundException e) { // 专门处理文件不存在的情况:比如提示用户检查路径,或者创建默认文件 } catch (IOException e) { // 处理其他IO错误:比如权限问题、磁盘满了 }
其次,语义明确性。看到方法签名抛出FileNotFoundException,调用者一眼就知道“这个操作可能因为文件不存在失败”,比只看IOException的信息量大得多,能提前做好对应的处理逻辑。
那细分的边界在哪里?你说的那些奇葩异常名(比如FileNotFoundButOnlyCheckedOnceException)确实没必要,因为它们没有对应的独特处理逻辑。异常细分的原则是:当某个异常场景需要被单独关注、单独处理,或者有独特的语义信息时,才值得单独建类。如果只是“文件没找到但重试过一次”这种情况,完全可以在IOException的cause里传递这个信息,或者用自定义的异常属性来标记,没必要单独搞一个类。
你的这个误解很有意思,但其实这类类依然是蓝图——它们是专门负责某一项单一职责的蓝图。比如FirstLineReader的职责就是“读取文件的第一行”,LastLineReader负责“读取文件的最后一行”,它们各自封装了对应的逻辑,你需要创建它们的实例来完成具体的读取操作。
为什么Spring里有很多这类类?因为这符合单一职责原则:一个类只做一件事,代码更清晰,维护起来更简单。如果把所有读取逻辑塞在一个GenericReader里,靠参数(比如ReaderType.FIRST_LINE)来控制行为,那这个类会越来越臃肿,分支判断越来越多,出了问题也难排查。而拆分后,每个类的逻辑都很纯粹,测试也更容易——你只需要测试FirstLineReader的读取第一行逻辑,不用关心其他情况。
所以这类设计是好的实践,不是把类当实例用,而是用类来封装单一职责的逻辑。
这完全取决于不同品种的Dog是否有行为差异:
- Option1(用enum):如果所有Dog的行为完全一致,只是属性不同(比如外观、体重范围),那用enum更合适。比如所有Dog的
bark()方法都是“汪汪叫”,只是Bulldog的体型更大,Poodle的毛更长,这时候用enum标记品种,在Dog类里根据品种处理属性,代码更简洁,不会出现类爆炸的问题。调用者创建Dog实例也很方便:new Dog(DogBreed.BULLDOG)。 - Option2(继承):如果不同品种的Dog有不同的行为,那继承就更合适。比如Bulldog的
bark()是“低沉的汪汪叫”,Poodle的bark()是“尖锐的汪汪叫”,或者Bulldog有自己的方法snore()(打呼噜),而Poodle没有。这时候用子类来封装各自的行为,符合多态的设计,调用者可以直接用Dog dog = new Bulldog(),然后调用dog.bark(),自动执行对应的逻辑,代码更灵活,扩展性也更好——以后加新的品种,只需要加一个子类就行,不用修改原来的Dog类。
另外要纠正你的一个误解:类永远是蓝图,实例是根据蓝图创建的对象。哪怕是Bulldog类,它也是Bulldog这个品种的蓝图,new Bulldog()才是具体的实例;FirstLineReader也是蓝图,new FirstLineReader()才是能干活的实例。“类代表实例”这个说法是不准确的,类是用来描述一类对象的共同特征和行为的模板。
- 异常细分:当需要单独捕获/处理,或者有独特语义时拆分,否则用属性或cause传递信息;
- 类的拆分:遵循单一职责原则,当某个职责足够独立、需要单独封装时,就建一个类;
- 继承vs枚举:看行为差异,行为一致用枚举,行为不同用继承(或者组合,不过这是另一个话题了)。
内容的提问来源于stack exchange,提问作者Koray Tugay

