ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Scala访问修饰符与Java的差异:从private到限定修饰符实战

Scala访问修饰符与Java的差异:从private到限定修饰符实战 我先说一个真实经历。有次团队代码评审一位从Java转过来的同事指着一行private[shop] def applyDiscount(...)问我“这里直接写成 private 不行吗为什么要加个包名加了之后跟 private 有什么区别”我当时就意识到Scala 访问修饰符这套东西表面上看就是 private、protected、public 三个词跟 Java 一字不差但实际语义差别非常大尤其是 protected 和“限定修饰符”几乎处处是坑。这篇文章我打算把这几年在 Scala 项目里和访问修饰符反复较劲的经验一次性倒出来。不光是讲清楚每个关键字是什么意思更重要的是讲明白为什么 Scala 要这么设计、在真实代码里怎么组合使用、以及编译成字节码后和 Java 互操作时哪些“保护”其实会失效。如果你正在从 Java 迁到 Scala或者写 Scala 时总感觉可见性控制得不够顺手这篇应该能帮你把很多模糊的概念彻底钉死。1. 先用一张表看清 Scala 和 Java 访问修饰符的差异Scala 的访问修饰符体系说到底是建立在 JVM 之上但又不完全服从 JVM 的那套直觉。很多 Java 程序员一开始不习惯不是因为看不懂 Scala 的写法而是因为脑子里带着 Java 的默认预期。所以我先把两边的主要差异铺成一张对照表后面再逐个拆开讲。对比维度Java 中的表现Scala 中的表现默认可见性package-private无修饰符public什么都不写就是公开private 的类内边界嵌套类可以访问外部类的 private 成员嵌套类不能直接访问外部类的 private 成员protected 的可见范围同包内所有类 子类仅子类同包的其他类不可见package-private 概念存在靠没有修饰符来表达不存在所有成员默认对全部代码可见限定访问修饰符没有有如 private[包名]、protected[包名]、private[this]这张表看着简单但里面每一行都能在真实项目中引发一场“你写的代码为什么编译不过”的讨论。下面我挑三个最容易让人栽跟头的点展开说。1.1 为什么 Scala 的“默认 public”和“没有 package-private”不是缺陷Java 里最常用到的其实是 package-private你写一个类不加任何修饰符它就只能被同一个包里的代码使用。这在搭建分层架构时很方便但代价也非常明显——一旦包组织得不够规范这个可见性到底生效到什么范围往往要翻半天目录结构才能确认。Scala 直接把这个机制拿掉了。成员默认全是 public等于天天逼你想清楚这个字段、这个方法到底要不要给别人用。如果你真的想限制可见性Scala 给了更精确的“限定修饰符”来做这件事而不是靠一个“我忘了写修饰符”的隐式保护。这个设计哲学很一致显式永远优于隐式。Java 的 package-private 是隐式的Scala 的private[包名]是显式的后者在代码阅读时一眼就能看出边界在哪。我第一次在 Scala 里写private[dao] def migrate的时候还有点不习惯后来发现这比 Java 里反复确认“这个类到底在哪个包下”要舒服得多。你直接在声明处写清楚“这个东西只给 dao 包内的人用”比靠目录结构猜强了不止一个量级。1.2 protected 的“收紧”才是 Java 程序员最难受的变化Java 的 protected 是“同包 子类”都能访问Scala 的 protected 只有“子类”能访问同包的其他类一概不行。这是一个很容易被忽略的语义差异。举个例子Java 里这样写是合法的package demo; public class Base { protected void work() { System.out.println(base); } } public class Helper { public void call(Base b) { b.work(); // Java 允许因为 Helper 和 Base 在同一个包 } }同样的逻辑换到 Scala编译直接报错package demo class Base { protected def work(): Unit println(base) } class Helper { def call(b: Base): Unit b.work() // 编译错误Scala 的 protected 不允许包内其他类访问 }这个差异为什么会存在我个人的理解是Java 的 protected 混入了“包内共享”的语义把两种完全不同的访问需求拧在了一起Scala 则把它们拆开了。继承关系就归 protected 管包内协作就归private[包名]管。等讲到限定修饰符时你会看到这种拆分其实让代码的表达力强了很多。所以当你从 Java 转过来千万别默认“同一个包就能访问 protected 成员”。我在一条业务代码里因为这个错误改了十分钟才反应过来。1.3 修饰符能用在哪不光是成员构造函数和类本身也能限制在 Scala 里private 和 protected 不光能加在字段和方法上还能加在类上、构造函数上、伴生对象上。最常见的是私有构造class User private (val name: String)这种写法下外部代码没法直接 new只能在伴生对象里提供工厂方法。还有更细的可以给 case class 的构造参数加限定访问修饰符case class Config private[core] (val endpoint: String, val timeout: Int)这行代码的意思是Config 的构造函数只对 core 包内的代码可见包外只能通过其他方式拿到 Config 实例。这是我在模块化项目中控制实例创建的常用手法等第 3 章我会专门展开。总之不要以为访问修饰符只能修饰成员它能修饰的范围比你印象中大得多。2. private 与 protected 的边界比表面语义要复杂很多教程讲 private 就是“类内可见”这句话在 Java 和 Scala 里都不完全准确。Scala 的 private 有个极其重要的例外伴生对象。而 protected 的边界又和 override 机制搅在一起容易出幺蛾子。这一章我们逐个抠。2.1 伴生对象唯一能突破 private 的“自己人”Scala 里 class 和它的伴生对象之间可以互相访问对方的 private 成员。这是编译器开的一个“后门”但它是刻意设计的。最典型的用途就是私有构造函数配合工厂方法class Order private (val id: Long) { private var status: String CREATED } object Order { def create(id: Long): Order { val o new Order(id) o.status INIT // 伴生对象可以访问 Order 的 private 字段 o } }在 Java 里你想实现类似效果一般要么把构造函数设为 package-private要么用反射要么接受一个额外的“构造参数对象”。Scala 用伴生对象的特例把这件事做得非常干净构造函数对外封闭但对伴生对象开放。我实际写下来感觉这个设计有两点要注意。第一伴生对象访问 private 成员时Scala 编译器会生成额外的访问方法字节码层面不是直接字段访问。这意味着你不能完全用“防君子也防小人”的心态看待 private它在 JVM 层并没有想象的那么严防死守。第二如果你在伴生对象里大量改写主类的 private 字段代码的可读性会快速下降因为类的状态被“外部”改了。我的建议是伴生对象里尽量少碰 private var优先用 private val 加工厂逻辑。2.2 嵌套类中 private 的不可见性丢掉 Java 惯性Java 的内部类可以随意访问外部类的 private 成员这在编写事件监听器、迭代器时特别常用。但 Scala 的嵌套类没有这个特权看下面的例子class Outer { private val secret hidden class Inner { def read: String secret // 编译错误Outer 的 private 成员对 Inner 不可见 } }第一次遇到这个报错时我的感觉是“凭什么不行”。后来想明白了Scala 的嵌套类默认不隐式持有外部类实例的引用它和外部类就是两个独立的类private 当然不共享。解决办法是用限定访问修饰符把可见性放宽到 Outer 这个类级别class Outer { private[Outer] val secret hidden class Inner { def read: String secret // 编译通过 } }这里的private[Outer]代表“Outer 类范围内的所有代码都可以访问”自然包含了嵌套类 Inner。这个细节特别值得记下来因为你会经常在 Scala 源码里看到这种用法它实际是在表达“类内部及嵌套结构之间共享”语义比 Java 的 private 更精确。2.3 protected 与 override可见性只能放大不能缩小Java 和 Scala 在重写方法的可见性规则上是一致的子类重写父类方法时可以放大可见性但不能缩小。也就是说父类里是 public 的方法子类 override 时不能改成 protected 或 private父类里是 protected 的方法子类可以把它变成 public。这个规则本身好懂但有一个和 Scala 的 protected 语义结合后容易踩的坑——子类里访问父类的 protected 成员没问题但是否能通过“父类实例”访问取决于这个实例是不是当前子类类型。看代码class Base { protected def label: String base } class Child extends Base { def show(b: Base): String b.label // 编译错误b 不是 Child 类型不能访问 protected label def showSelf(): String this.label // 编译通过 }这个规则其实 Java 也一样但因为 Scala 的 protected 本来就比 Java 更严很多人想当然地以为“我是子类所以我什么 protected 都能碰”一写就错。你在设计抽象类、模板方法模式时记得 protected 成员只能在“自己的继承链内部”传递和具体的实例类型强相关。3. 限定访问修饰符Scala 真正独有的“可见性调节器”前面反复提到private[包名]现在正式展开。这一类带中括号的修饰符是 Scala 从 Java 的可见性体系里长出来的最大亮点也是很多 Scala 源码读起来“有味道”的核心原因。3.1 private[包名] 到底把可见性画在了哪private[包名]的语义是以指定包名为边界从该包往内的所有代码都能访问。比如package com.example.store class Inventory private[store] { def restock(): Unit println(restock) }假设存在包com.example.store.service它内部的代码可以访问 Inventory 的 restock 方法但com.example.other包里的代码不能。private[store]相当于把 package-private 升级成了“可以用任意外层包名来画线”的版本。有一点必须强调private[包名]不要求包名必须是当前类所在的直接包。你可以写private[com]意思是“com 包及它所有子包内的代码都能访问”。包名写的越靠外可见范围越大。所以在实际项目里团队一般会约定内部 API 用private[模块名]更小的接口用private[子包名]这样读代码的人一眼就能看明白这条可见性边界有多宽。字节码层面的实现也值得知道JVM 没有“包级私有的任意包名限定”这种概念所以 Scala 编译器在生成字节码时通常把private[包名]成员编译成 public。**也就是说Scala 这个限制只在编译期生效Java 侧乃至反射都可以绕过。**这一点第 4 章还会详细讲。3.2 protected[包名] 与 private[this]两个容易被忽略的变体protected[包名]是private[包名]的增强版除了指定包内可见还允许所有子类访问。这在抽象类设计里很有用比如package app.core abstract class Base { protected[core] def hook(): Unit () }这样core包内的一般类可以直接调用 hook其他包里如果有一个类继承了 Base也可以在自己的方法里调用 hook。它是 Scala 对“包内协作 继承扩展”两种需求的合体表达。另一个变体private[this]更严格。它表示“只有当前这个实例能访问”连带伴生对象都不能碰class Stats { private[this] var hits: Long 0L def add(): Unit hits 1 } object Stats { def make(): Stats { val s new Stats // s.hits 1 // 编译错误private[this] 连伴生对象都访问不了 s } }我通常建议只在两种场景下使用private[this]一是你明确知道这个字段只在当前实例内部使用且想告诉编译器“不要生成访问器方法”从而少一层间接调用二是写一些对性能敏感或需要避免字段逃逸的代码时。日常业务代码里private带伴生对象的灵活性比private[this]更实用没必要处处用this限制自己。3.3 用限定访问修饰符设计“半个公开 API”的实战样例限定修饰符最常见的落地场景是给一个模块设计“对外隐藏、对内开放”的半公开 API。我拿一个缓存组件举例package app.cache class TTLCache private[app] ( private val capacity: Int, private[app] val evictionPolicy: String ) { private[this] var hitCount: Long 0L def get(key: String): Option[String] { hitCount 1 lookup(key) } private def lookup(key: String): Option[String] None private[app] def clear(): Unit { hitCount 0L // 真正清空缓存的逻辑 } }逐个拆开看class TTLCache private[app]整个类只有app包内能 new外围业务模块拿不到构造入口只能通过工厂获取。capacity是真正的 privateTTLCache 内部和伴生对象可见其余地方一律不可见。evictionPolicy是private[app]表示 app 包内的其他模块比如监控模块可以读取它但 app 包外的代码拿不到。hitCount是private[this]它完全不生成访问器也不会被伴生对象意外改写。clear()方法用private[app]让包内的管理后台能触发清空但对外部使用者隐藏。这套组合比 Java 里“要么 public、要么 private、要么包私有”的三选一灵活太多了。你可以在同一个类里同时设立“外部只读边界”“模块内修改边界”和“实例内私有边界”每一层边界都写在了声明处后续维护的人不需要四处翻调用点就能理解设计意图。4. 编译后去哪儿了访问修饰符与 Java 互操作的三个真相很多 Scala 项目不是纯 Scala 的总有一部分历史代码或者外部 SDK 是 Java。一旦涉及跨语言调用访问修饰符的真实行为就变得非常关键——因为你看到的 Scala 源码和 Java 侧“看到”的可见性往往不是一回事。4.1 为什么字节码里 private[包] 会变成 publicJVM 字节码层面只认四种访问级别public、protected、private 和 package-private。Java 的 package-private 在字节码里没有显式修饰符Scala 的private[包名]并没有对应的字节码级别编译器只能把它放宽成 public同时依赖编译期检查来保证 Scala 内部的可见性约束。这意味着一个残酷的事实**Scala 里写private[core] def internal在 Java 代码里是可以直接调用的Java 编译器根本不知道 Scala 的这条限制。**如果你用 Java 写单元测试或者加一些兼容层就可能绕开 Scala 的约束破坏模块边界。知道这一点之后我养成了一个习惯凡是 Scala 侧用限定修饰符保护的关键方法在 Java 侧调用时必须做 code review 标记不能因为“IDE 里能补全出来”就默许使用。4.2 从 Java 侧调用 Scala 成员时最容易踩的三种情况第一种是 Java 同包访问 Scala 的 protected 成员。Java 的 protected 允许同包访问所以一个 Java 类如果和 Scala 基类在同一个包它能直接调用 Scala 的 protected 方法。这在 Scala 侧是明确禁止的但 Java 侧会“悄悄放行”。第二种是调用private[包]成员。刚才说了编译成 publicJava 无感访问。如果这个成员是内部状态修改方法Java 侧调用可能引发不期望的副作用。第三种是绕过私有构造函数。Scala 的class Foo private (...)编译后构造函数在字节码层面通常还是 private 的Java 直接 new 会报错。但如果你有伴生对象而伴生对象方法范访问了私有构造函数Scala 编译器生成的辅助逻辑可能额外暴露一些 static 方法。这时候 Java 侧看类的结构会发现比 Scala 源码里多出不少“隐藏方法”。如果你在 Java 里用反射或者 IDE 的 structure view不要被这些方法吓到那是伴生对象互访机制的正常产物。4.3 给 Java 用 API 时如何“正确地放宽”当你明确知道某个 Scala 组件需要被 Java 调用就不要依赖 Scala 的访问修饰符去限制 Java 侧了。我更推荐的做法是在 Scala 里设计一个专门的“Java 友好门面”把真正需要开放的方法放在门面上内部实现仍然用严格的访问修饰符锁住。class InternalEngine private[app] { private[app] def run(): Unit () def doWork(): Unit () } object JavaFacade { def execute(engine: InternalEngine): Unit engine.doWork() }Java 那边只面向 JavaFacade 编程不直接触碰 InternalEngine 的内部细节。这样即使 Java 能绕过 Scala 的限制你的代码组织上也没给“绕行”留出口。另外如果需要给 Java 暴露 JavaBean 风格的 getter/setter可以用BeanProperty注解自动生成但要注意它生成的访问器可见性和原字段一致别把不该公开的字段用注解变成 public。5. 一个完整设计案例外部只读、包内可改、子类可扩展的订单模型讲了这么多概念最后用一个我常用的案例把所有东西串起来。这个案例是我在写订单服务时反复打磨出来的模式非常适合理解 Scala 访问修饰符的组合价值。5.1 需求约束假设你在设计一个订单模块约束条件有四条外部代码不能直接 new Order必须通过伴生对象的工厂方法创建。订单金额对外是只读的不能让外部随便改。同一个包内的订单服务比如 OrderService可以调整金额比如应用折扣。不同订单类型标准订单、促销订单可以扩展自己的折扣校验逻辑。拆解下来就是构造入口要封闭、读接口要公开、写接口要模块内开放、扩展点要保护起来。5.2 组合实现与逐行解释package shop sealed abstract class Order protected (val id: Long) { protected var _total: Double 0.0 def total: Double _total protected def canApplyDiscount(discount: Double): Boolean private[shop] def applyDiscount(discount: Double): Unit { if (canApplyDiscount(discount)) { _total - discount } } } object Order { def create(id: Long): Order new StandardOrder(id) } class StandardOrder private[shop] (id: Long) extends Order(id) { protected def canApplyDiscount(discount: Double): Boolean discount 0 (_total - discount) 0 }逐行分析abstract class Order protected (val id: Long)构造函数是 protected 的外部不能 new伴生对象和子类可以。sealed进一步限制只有当前文件内的类能继承它避免出现“隔了三个包突然冒出一个 Order 子类”的情况。protected var _total可变的内部金额子类可以访问外部不行。def total真正对外开放的读接口返回 Double外部只能读不能写。protected def canApplyDiscount模板方法模式里的扩展点子类实现自己的规则。它在 Order 的伴生对象之外不能被别的代码调用这是 Scala 的 protected 语义比 Java 干净的地方——没有把包内访问混进来。private[shop] def applyDiscount这是整个设计的核心。外部拿不到这个方法但shop包内的 OrderService 可以调用它来给订单打折。它调用了子类实现的canApplyDiscount从而把“何时能打折”的策略开放给了子类“谁来调用打折”的权限交给了包内。object Order.create伴生对象访问 protected 构造通过工厂方法对外统一入口。class StandardOrder private[shop]标准订单类本身也只对 shop 包可见。这套代码跑起来的效果是外部的 Controller 层只能Order.create(id)、order.total做不了任何修改操作shop包内的 OrderService 可以调用applyDiscount完成业务操作新的订单类型只要继承 Order、实现canApplyDiscount就能接入。三条可见性边界在声明处都非常明确不需要翻遍整个项目去猜。5.3 复盘与团队规范建议这个模式我用过多次踩过也调整过。最大的心得是三句话第一public 成员要单独评审。Scala 默认全部 public所以代码 review 时抓住“哪些成员没有显式修饰符”这条线就能快速找出可能被过度暴露的 API。团队里可以定一条规矩所有 public 成员必须在一行注释里说明为什么公开否则默认改成 private。第二包内共享优先用 private[包] 而不是 public 或 protected。很多团队为了避免跨包复用就把成员改成 public这等于放弃了编译期保护。private[包]是一种性价比很高的选择编译期检查、IDE 提示准确、代码意图清晰。第三protected 只应该出现在“本来就会被继承”的类里。如果你写了一个 class它不是 abstract、也没有任何子类结果里面有一个 protected 方法那基本是设计欠考虑。Scala 的 protected 比 Java 更严格但越严格越需要自律不要给不准备被继承的类随便预留 protected 扩展点把接口做小做稳比提前给各种钩子要好。最后分享一个排查技巧。当你在 IDE 里看到一个黄色警告提示某个成员“在当前上下文不可访问”先别急着把修饰符从 private 改成 public。先看一眼它是不是真的有跨包访问需求如果是优先考虑private[包名]如果不是多半是代码的包结构设计出了问题——比如一个内部逻辑被不小心放到了对外 API 的包里。调整包结构比放宽访问修饰符更符合 Scala 的设计哲学也更符合团队长期维护的利益。这套思路我用了很久每次照做都能少引入几个隐蔽的 API 泄漏问题。
返回列表