三轮练习复盘:泛型擦除的边界、new 的步骤、LEFT JOIN 计数与快照读

三轮练习复盘:泛型擦除的边界、new 的步骤、LEFT JOIN 计数与快照读

这一篇整理 10 月 21 日的三轮练习。Java 那轮继续泛型,从「擦到哪个类型」问到「为什么建不出泛型数组」;前端那轮拆了 new 的执行步骤,问了父组件怎么反过来调子组件的方法,还有 position: fixed 在什么情况下不再相对视口;数据库/网络那轮把一对多 JOIN 的计数、ON 与 WHERE 的位置、可重复读下快照读和当前读混用的问题放在了一起,末尾接 JWT 的登出与续期。

泛型的上界擦除:T 擦成哪个类型

Java 的类型擦除规则按 JLS 4.6 的定义,类型变量的擦除结果是它最左侧的那个边界。没有写 extends 时边界是 Object,写了上界就擦成那个上界。所以下面这个方法在字节码里,T 的位置实际是 Comparable。

public <T extends Comparable<T>> T max(List<T> list) { ... }

写成两个上界时,擦成排在前面的那个,也就是 Number:

public <T extends Number & Comparable<T>> T max(List<T> list) { ... }

上界决定了方法体里能对 T 调用什么。擦除之后编译器只知道 T 是 Number 和 Comparable,所以 Number 的 intValue() 与 Comparable 的 compareTo() 都能调,其它方法一律不行。两个上界的方法都会保留,不是只留前一个。

擦成裸的 Comparable 还带来一个副作用:源码里写 t.compareTo(x) 时,编译器在字节码里按 raw 类型调用,返回值再插一句强转。这也解释了昨天那个现象:List<String> 的 get(0) 赋值给 String 变量时抛出的 ClassCastException,位置看着在调用处,实际是编译器插入的那句转换。

参考:JLS 4.6 Type Erasure

List<Object> 不能接 List<String>:通配符的读写边界

数组那边是允许的,Object[] arr = new String[10] 能编译,因为数组是协变的,它在运行时也知道自己装的是 String,往里面塞不兼容的类型会立刻抛 ArrayStoreException。泛型没有这个运行时信息,擦除之后两个 List 都是 List,装的是什么只剩编译器知道。所以编译器直接禁止把 List<String> 赋给 List<Object>。

如果允许这么写,下一步就会出问题:

List<Object> list = new ArrayList<String>();
list.add(123);              // 编译通过
String s = (String) list.get(0);  // 运行时 ClassCastException

往一个名义上是 List<Object> 的容器里塞 Integer,实际写入的是 String 的列表,取出来再按 String 用就会炸。这种「运行时装的东西和类型参数对不上」的状态叫堆污染。

参数位置上的写法我一开始给的是 <? extends Object>,这个能编译,但它等价于 <?>,多写的边界没有作用。要让方法既接 List<String> 和 List<Integer> 且只读,写 List<?> 就够了;如果这些类型有共同父类型,写 List<? extends Number> 更有信息量,读出来的元素也能按 Number 用。

extends 和 super 的区别在于边界的方向。用 ? extends T 时,元素可以读出来当作 T 用,但不能写,因为编译器不知道实际是 T 的哪个子类型,写任何一个都可能破坏类型安全,唯一的例外是写 null。用 ? super T 时反过来,可以写入 T 及其子类型,读出来只能当 Object。这就是 PECS 那句 Producer extends, Consumer super 的来历。

参考:Oracle 通配符使用指南

泛型数组为什么建不出来

new List<String>[10] 直接是编译错误,报 generic array creation。按 JLS 15.10.1,数组创建时如果元素类型不是可具体化的类型(reifiable),编译就不通过。原因是数组把元素类型带到了运行时,写入时靠它做检查;而 List<String>[] 擦除之后只是 List[],运行时没有 String 这个信息,无法承担检查。语言干脆不让创建这种数组,从源头上避免漏洞。

对比 new List[10] 是能过的,因为那是裸类型,元素类型 List 本身是可具体化的,只是编译器会给一条 unchecked 告警。

方法里想要 T 的数组也一样不行,T[] arr = new T[10] 编译不过,擦除之后 T 是什么编译器不知道。实际做法是把类型当成参数传进来,用反射创建:

@SuppressWarnings("unchecked")
static <T> T[] newArray(Class<T> clazz, int n) {
    return (T[]) java.lang.reflect.Array.newInstance(clazz, n);
}

另一个常见写法是 (T[]) new Object[n],它在运行时其实是 Object 数组,赋值给具体类型的数组引用时仍有强转风险,用起来要更小心。日常更省事的做法是直接用 List<T> 替代数组。

参考:JLS 15.10.1 Array Creation Expressions

new 到底做了哪几步

按 ECMAScript 的 [[Construct]] 语义,new Student('张三', 3) 依次做四件事:创建一个空对象;把这个对象的 [[Prototype]] 指向 Student.prototype;以这个新对象作为 this 执行构造函数体并传入参数;按返回值决定最终返回什么。

返回值这一步有两条规则。构造函数返回一个对象(数组、函数也算)时,new 的结果就是那个对象,原来创建的对象被丢掉。返回基本类型时返回值被忽略,结果仍是新创建的对象。所以 return { a: 1 } 得到的是 { a: 1 },return 1 得到的还是 Student 实例。

instanceof 的判断走的是原型链,检查实例的原型链上有没有这个构造函数当前的 prototype。因为找的是「当前的」prototype,继承写法会改变判断结果:执行 Student.prototype = new Person() 之后,Student 实例的原型链里含有一个 Person 实例,而这个 Person 实例的原型是 Person.prototype,所以 s instanceof Person 也为 true。

参考:ECMA-262 [[Construct]]

通过 ref 访问子组件:defineExpose 与状态提升

父组件拿到 childRef.value 时,得到的不是子组件的全部内部状态,而是子组件对外公开的实例代理。用 <script setup> 写的组件默认是封闭的,没有显式暴露的东西外面拿不到,所以调 reset() 会报 reset is not a function。子组件里加一句就行:

defineExpose({ reset })

反过来,如果父组件要的是子组件里的数据,更常规的做法是把这份状态提到父组件。数据放父级,子组件用 props 接、用 emit 往上抛,或者直接 v-model 双向绑定。这样父组件任何时候都握着最新值,不需要反过来掏子组件的内部。

版本上有一点差别:Vue 3.5 之后可以用 useTemplateRef 取得模板引用,写法比定义 ref 变量再同名绑定更直接。

参考:Vue 文档 defineExpose

position: fixed 什么时候不再相对视口

fixed 默认相对视口定位,但这个前提是它的包含块就是视口。CSS 里有一批属性会让元素成为后代的包含块,其中和这里相关的是 transform、filter、perspective、backdrop-filter、will-change 指定了这几类属性,以及 contain 的 paint、layout、strict、content。祖先一旦带了这些属性,fixed 后代就改为相对这个祖先定位。

这解释了那个常见现象:全屏遮罩放在带入场动画(transform: translateX)的容器里,遮罩铺不满屏幕,因为它的 100% 变成了相对那个容器算的。

常规解法是把弹窗挂到 body 下,Vue 里用 Teleport:

<Teleport to="body">
  <div class="modal-mask">...</div>
</Teleport>

组件库的 Modal 默认挂 body 也是这个考虑,另外还避开了祖先的 overflow 裁切和层叠上下文带来的 z-index 问题。

参考:MDN position

LEFT JOIN 的计数,以及条件放在 ON 还是 WHERE

一对多关联下,COUNT(*) 数的始终是结果集的行数。没下过单的用户在 LEFT JOIN 后仍然会保留一行,右表的列全部补成 NULL,这一行照样计入,所以结果是 1 而不是 0。我当时的判断是「统计以主表为准」,结论对,但要落到 COUNT 的参数上才完整。

我补的那句是「可以写 o.*」,这个写法在 MySQL 里通不过:COUNT() 的参数必须是一个表达式,o.* 不是表达式,星号只有在单独写成 COUNT(*) 时才被当作「数行」特判。正确的做法是数右表的某一列,例如 COUNT(o.id)。COUNT(expr) 只统计非 NULL 的值,右表没匹配上时 o.id 是 NULL,这一行就被跳过了,没下单自然显示 0。

SELECT u.id, COUNT(o.id) AS order_cnt
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;

第二个问题关于条件的位置。ON 是连接条件,参与的是「右表哪几行能和左表这一行配上」;WHERE 作用在连接完成之后,过滤整个结果集。所以下面两条不等价:

-- A:状态条件在连接阶段生效,没订单的用户保留
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 1

-- B:连接之后整体过滤,NULL 行被 WHERE 扔掉
LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 1

B 里 o.status = 1 对 NULL 求值结果是 NULL,不是 true,这些行会被滤掉,没下过单的用户整行消失,LEFT JOIN 的效果退化成 INNER JOIN。要保留左表全部用户又只统计已支付订单,条件必须写在 ON 里,或者写成 WHERE o.status = 1 OR o.id IS NULL 这种显式把 NULL 放行的形式。

参考:MySQL 8.4 JOIN Clause

可重复读里的快照读与当前读

RR 级别下普通 SELECT 是一致性读,读的是事务开始时的快照,靠 undo log 里的旧版本构造出来,不加锁,也不会被别的会话的写阻塞。UPDATE、DELETE、SELECT ... FOR UPDATE 是当前读,读最新已提交的版本,并且要加锁。这就是同一条 SQL 在同一个事务里读数不同的来源。

BEGIN;
SELECT COUNT(*) FROM orders WHERE amount > 100;   -- 10
-- 另一个会话插入一条 amount = 200 并提交
SELECT COUNT(*) FROM orders WHERE amount > 100;   -- 仍是 10
UPDATE orders SET status = 1 WHERE amount > 100;  -- 影响到 11 行
SELECT COUNT(*) FROM orders WHERE amount > 100;   -- 11

第四条返回 11,是因为这个事务自己改过那批行。InnoDB 的一致性读对「本事务自己产生的修改」是可见的,快照里没有、但自己写过的行会出现。所以严格说,这个隔离级别挡的是别的事务造成的不可重复读和大部分幻读,挡不住自己写操作引起的读结果变化。这也是为什么同一个事务里混着快照读和当前读,很容易写出看着矛盾的读数。

参考:MySQL 8.4 Consistent Nonlocking Reads

JWT 的失效与续期

JWT 的签名只保证内容没被改过,它不提供撤销能力,因为服务端不存这个 token 的任何状态。要让一个还没到期的 token 立刻失效,就得额外引入状态。常见做法是版本号:用户表或缓存里存一个当前版本,token 里带上用户 id 和这个版本号,登出或强制下线时把版本加一,旧 token 的版本对不上即失效。另一种是黑名单,把要作废的 token 的 jti 存到过期为止。两种方案都要在验证时读一次状态,区别在于版本号只占一个字段,黑名单随时间增长。

access token 设短、另配一个长效 refresh token,这套组合换的是撤销延迟和性能。access token 只验签不查库,所以校验快,代价是签发后在这段时间里没法撤销;把有效期压到 15 分钟,最坏情况下的撤销延迟也就是 15 分钟。refresh token 有效期长,必须能被撤销,所以换新的时候要查一次存储,确认这个 refresh token 还在、没被登出或者踢下线。

参考:RFC 7519 JSON Web Token

← 返回文章列表