引子:List.of 返回的到底是什么?
大家日常写 Java 代码时,一个创建小列表的常见方式如下 ⬇️
List<Integer> list = List.of(1, 2, 3);
它看起来平平无奇,但有几个问题 ⬇️
- 它返回的是
ArrayList吗? - 为什么
get(i)是 的,底层是数组还是链表? List.of(1)和List.of(1, 2)的内部结构一样吗?- 为什么同样是越界访问,
List.of(1).get(1)抛IndexOutOfBoundsException,而List.of(1, 2, 3).get(3)抛ArrayIndexOutOfBoundsException?
我们先给出结论,再逐条验证。本文基于 OpenJDK 21,源码引用自 JDK 21 的 java.util.List 与 java.util.ImmutableCollections。
正文
一张表看完分派规则
List.of 有 12 种重载形式(0~10 个参数的固定重载,再加一个可变参数重载)。在 java.util.List 我们可以找到以下方法 ⬇️
static <E> List<E> of();
static <E> List<E> of(E e1);
static <E> List<E> of(E e1, E e2);
static <E> List<E> of(E e1, E e2, E e3);
static <E> List<E> of(E e1, E e2, E e3, E e4);
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5);
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6);
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7);
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8);
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9);
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10);
static <E> List<E> of(E... elements);
参数的 个数 决定实现类 ⬇️
| 参数个数 | 实现类 | 底层存储 | 元素数组的分配 |
|---|---|---|---|
ImmutableCollections$ListN | 共享的 EMPTY_LIST | 类初始化时创建一次,之后复用 | |
ImmutableCollections$List12 | 两个 final 字段:e0、e1 = EMPTY 哨兵 | 0 次 | |
ImmutableCollections$List12 | 两个 final 字段:e0、e1 | 0 次 | |
ImmutableCollections$ListN | Object[n],长度恰好 n | 1 次(复用调用点的数组,不再复制) | |
ImmutableCollections$ListN | Object[n],长度恰好 n | 额外复制 1 份(先复制再查 null) |
一句话概括:元素不超过 2 个时用两个字段,超过 2 个才创建数组;空列表则是一个全局共享的实例。
怎么看到这些私有字段
通过反射就能访问私有字段 ⬇️ (请将以下代码保存为 ListProbe.java)
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class ListProbe {
public static void main(String[] args) throws Exception {
List<List<Integer>> lists = new ArrayList<>();
lists.add(List.of());
lists.add(List.of(42));
lists.add(List.of(1, 2));
lists.add(List.of(1, 2, 3));
lists.add(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
lists.add(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
for (List<Integer> list : lists) {
Class<?> c = list.getClass();
List<String> fields = new ArrayList<>();
for (Field f : c.getDeclaredFields()) {
fields.add(f.getType().getSimpleName() + " " + f.getName());
}
StringBuilder builder = new StringBuilder();
builder.append(String.format("size=%-2d %-44s [%s]", list.size(), c.getName(),
String.join(", ", fields)));
if (list.size() >= 3) {
Field f = c.getDeclaredField("elements");
f.setAccessible(true);
builder.append(" elements.length=").append(((Object[]) f.get(list)).length);
}
System.out.println(builder);
}
}
}
从 JDK 9 开始,反射访问 java.base 的私有字段需要显式开放包 ⬇️
javac ListProbe.java
java --add-opens=java.base/java.util=ALL-UNNAMED ListProbe
在我本地(OpenJDK 21)输出如下 ⬇️
size=0 java.util.ImmutableCollections$ListN [Object[] elements, boolean allowNulls]
size=1 java.util.ImmutableCollections$List12 [Object e0, Object e1]
size=2 java.util.ImmutableCollections$List12 [Object e0, Object e1]
size=3 java.util.ImmutableCollections$ListN [Object[] elements, boolean allowNulls] elements.length=3
size=10 java.util.ImmutableCollections$ListN [Object[] elements, boolean allowNulls] elements.length=10
size=11 java.util.ImmutableCollections$ListN [Object[] elements, boolean allowNulls] elements.length=11
如果忘了加 --add-opens,setAccessible(true) 会抛 java.lang.reflect.InaccessibleObjectException ⬇️ 这也是本文配套 GUI 程序启动时先做预检的原因。
1 个和 2 个元素:List12 用两个字段撑起整个列表
List12 的源码如下 ⬇️ (本文不关心的方法,其代码都用 ... 表示)
@jdk.internal.ValueBased
static final class List12<E> extends AbstractImmutableList<E>
implements Serializable {
@Stable
private final E e0;
@Stable
private final Object e1;
List12(E e0) {
this.e0 = Objects.requireNonNull(e0);
// Use EMPTY as a sentinel for an unused element: not using null
// enables constant folding optimizations over single-element lists
this.e1 = EMPTY;
}
List12(E e0, E e1) {
this.e0 = Objects.requireNonNull(e0);
this.e1 = Objects.requireNonNull(e1);
}
@Override
public int size() {
return e1 != EMPTY ? 2 : 1;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
@SuppressWarnings("unchecked")
public E get(int index) {
if (index == 0) {
return e0;
} else if (index == 1 && e1 != EMPTY) {
return (E)e1;
}
throw outOfBounds(index);
}
@Override
public int indexOf(Object o) {
Objects.requireNonNull(o);
if (o.equals(e0)) {
return 0;
} else if (e1 != EMPTY && o.equals(e1)) {
return 1;
} else {
return -1;
}
}
@Override
public int lastIndexOf(Object o) {
Objects.requireNonNull(o);
if (e1 != EMPTY && o.equals(e1)) {
return 1;
} else if (o.equals(e0)) {
return 0;
} else {
return -1;
}
}
@java.io.Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
...
}
@java.io.Serial
private Object writeReplace() {
...
}
@Override
public Object[] toArray() {
...
}
@Override
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) {
...
}
}
几个值得注意的点 ⬇️
- 没有
size字段:size()靠e1 != EMPTY判断:存了哨兵就是 1 个元素,否则是 2 个。 e1声明为Object而不是E:因为它要装哨兵EMPTY(一个普通的new Object()),不能保证是E类型。- 哨兵不用
null:源码注释写明了原因, enables constant folding optimizations over single-element lists get:get(0)读e0,get(1)才读e1;indexOf也是直接比较两个字段,最多 2 次equals,没有循环。
越界时抛出的异常来自父类的方法 ⬇️
IndexOutOfBoundsException outOfBounds(int index) {
return new IndexOutOfBoundsException("Index: " + index + " Size: " + size());
}
实测结果示例 ⬇️
List.of(7).get(1) -> java.lang.IndexOutOfBoundsException: Index: 1 Size: 1
List.of(7, 8).get(2) -> java.lang.IndexOutOfBoundsException: Index: 2 Size: 2
3 个及以上:ListN 就是一个长度恰好为 n 的数组
ListN 的源码如下 ⬇️ (本文不关心的方法,其代码都用 ... 表示)
@jdk.internal.ValueBased
static final class ListN<E> extends AbstractImmutableList<E>
implements Serializable {
@Stable
private final E[] elements;
@Stable
private final boolean allowNulls;
// caller must ensure that elements has no nulls if allowNulls is false
private ListN(E[] elements, boolean allowNulls) {
this.elements = elements;
this.allowNulls = allowNulls;
}
@Override
public boolean isEmpty() {
return elements.length == 0;
}
@Override
public int size() {
return elements.length;
}
@Override
public E get(int index) {
return elements[index];
}
@java.io.Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
...
}
@java.io.Serial
private Object writeReplace() {
...
}
@Override
public Object[] toArray() {
...
}
@Override
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) {
...
}
@Override
public int indexOf(Object o) {
if (!allowNulls && o == null) {
throw new NullPointerException();
}
Object[] es = elements;
for (int i = 0; i < es.length; i++) {
if (Objects.equals(o, es[i])) {
return i;
}
}
return -1;
}
@Override
public int lastIndexOf(Object o) {
if (!allowNulls && o == null) {
throw new NullPointerException();
}
Object[] es = elements;
for (int i = es.length - 1; i >= 0; i--) {
if (Objects.equals(o, es[i])) {
return i;
}
}
return -1;
}
}
对比 List12,差别很直接 ⬇️
- 数组长度恰好等于元素个数,没有为了扩容预留的空位。
size()就是elements.length,get(i)就是elements[i],所以时间复杂度是 。- 构造器本身不复制、也不检查 null,注释写明"调用方必须保证没有
null"。null检查发生在构造之前的工厂方法里(见下一节)。 indexOf是线性扫描,从下标0开始用Objects.equals逐个比较,第一个匹配就返回。allowNulls可供Stream.toList()这类内部路径使用;List.of传的是false,所以List.of(...)一定拒绝null。
因为 get 直接把下标交给数组,越界异常由 JVM 抛出 ⬇️
List.of(1, 2, 3).get(3) -> java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
List.of(1, 2, 3).get(-1) -> java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 3
List.of().get(0) -> java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
为什么 3~10 个参数和 11 个以上不一样
List.of 里 3~10 个参数的固定重载统一走 listFromTrustedArray (以 3 个入参为例) ⬇️
static <E> List<E> of(E e1, E e2, E e3) {
return ImmutableCollections.listFromTrustedArray(e1, e2, e3);
}
而 listFromTrustedArray 会把收到的 Object[] 直接当作内部存储 ⬇️
@SuppressWarnings("unchecked")
static <E> List<E> listFromTrustedArray(Object... input) {
assert input.getClass() == Object[].class;
for (Object o : input) { // implicit null check of 'input' array
Objects.requireNonNull(o);
}
return switch (input.length) {
case 0 -> (List<E>) ImmutableCollections.EMPTY_LIST;
case 1 -> (List<E>) new List12<>(input[0]);
case 2 -> (List<E>) new List12<>(input[0], input[1]);
default -> (List<E>) new ListN<>(input, false);
};
}
11 个及以上参数会落到可变参数重载,走另一条路 ⬇️
@SafeVarargs
@SuppressWarnings("varargs")
static <E> List<E> of(E... elements) {
switch (elements.length) { // implicit null check of elements
case 0:
@SuppressWarnings("unchecked")
var list = (List<E>) ImmutableCollections.EMPTY_LIST;
return list;
case 1:
return new ImmutableCollections.List12<>(elements[0]);
case 2:
return new ImmutableCollections.List12<>(elements[0], elements[1]);
default:
return ImmutableCollections.listFromArray(elements);
}
}
listFromArray 面对的是调用方可能仍然持有的数组,所以必须先复制、再逐个检查 null ⬇️
static <E> List<E> listFromArray(E... input) {
// copy and check manually to avoid TOCTOU
E[] tmp = (E[])new Object[input.length]; // implicit nullcheck of input
for (int i = 0; i < input.length; i++) {
tmp[i] = Objects.requireNonNull(input[i]);
}
return new ListN<>(tmp, false);
}
注释里的 TOCTOU 指的是"检查完之后、复制之前数组被改掉"的时间窗口。先复制一份私有的 tmp,后续检查针对副本,就不会有这个问题。
这个差异可以直接观察到 ⬇️
Integer[] src = {1, 2, 3};
List<Integer> list = List.of(src); // 走可变参数重载,内部复制了一份
src[0] = 99;
System.out.println(list.get(0)); // 1,原数组的修改影响不到列表
0 个元素:全局共享的 EMPTY_LIST
List.of() 不新建对象,直接返回 ImmutableCollections.EMPTY_LIST ⬇️
static {
CDS.initializeFromArchive(ImmutableCollections.class);
if (archivedObjects == null) {
EMPTY = new Object();
EMPTY_LIST = new ListN<>(new Object[0], false);
// ...
archivedObjects = new Object[] { EMPTY, EMPTY_LIST, ... };
} else {
EMPTY = archivedObjects[0];
EMPTY_LIST = (ListN)archivedObjects[1];
// ...
}
}
由此可以推出两个结论 ⬇️
List.of()返回的不是List12,而是ListN(elements长度为 0)- 调用
List.of()得到的是同一个对象,那么List.of() == List.of()为true
关于 CDS,我问了问 trae (完整的回答有点长,这里直截取了开头的部分)⬇️ 有兴趣的读者可以自己探索一下(作者水平有限,本文中就不讨论这个话题了)
"不可变"是怎么做到的
不可变不是靠约定,而是靠父类把所有修改方法都覆写成抛异常。AbstractImmutableCollection 负责 Collection 层面的方法 ⬇️
abstract static class AbstractImmutableCollection<E> extends AbstractCollection<E> {
// all mutating methods throw UnsupportedOperationException
@Override public boolean add(E e) { throw uoe(); }
@Override public boolean addAll(Collection<? extends E> c) { throw uoe(); }
@Override public void clear() { throw uoe(); }
@Override public boolean remove(Object o) { throw uoe(); }
@Override public boolean removeAll(Collection<?> c) { throw uoe(); }
@Override public boolean removeIf(Predicate<? super E> filter) { throw uoe(); }
@Override public boolean retainAll(Collection<?> c) { throw uoe(); }
}
AbstractImmutableList 再补上 List 专属的修改方法 ⬇️
// all mutating methods throw UnsupportedOperationException
@Override public void add(int index, E element) { throw uoe(); }
@Override public boolean addAll(int index, Collection<? extends E> c) { throw uoe(); }
@Override public E remove(int index) { throw uoe(); }
@Override public void replaceAll(UnaryOperator<E> operator) { throw uoe(); }
@Override public E set(int index, E element) { throw uoe(); }
@Override public void sort(Comparator<? super E> c) { throw uoe(); }
异常工厂方法本身很朴素 ⬇️
static UnsupportedOperationException uoe() { return new UnsupportedOperationException(); }
注意它不带 message。所以实测日志里看到的是 ⬇️
add(1) -> UnsupportedOperationException (no message)
remove(1) -> UnsupportedOperationException (no message)
clear() -> UnsupportedOperationException (no message)
同样被拦住的还有视图和迭代器:subList(...) 得到的是一个只读视图,subList.clear() 抛 UOE;iterator().remove()、listIterator().set(...) 也都抛 UOE。
为什么越界异常类型不一样
两段源码已经把原因写清楚了 ⬇️
List12.get自己判断下标,抛outOfBounds(index)→IndexOutOfBoundsException,消息是Index: n Size: size。ListN.get直接return elements[index],边界检查交给 JVM →ArrayIndexOutOfBoundsException,消息是Index n out of bounds for length len。
这一点在写代码时有个实际结论:ArrayIndexOutOfBoundsException 是 IndexOutOfBoundsException 的子类,所以 ⬇️
try {
list.get(index);
} catch (IndexOutOfBoundsException e) { // 两种实现都能接住
// ...
}
而如果你只 catch (ArrayIndexOutOfBoundsException e),在单元素或双元素列表上就会出现漏过的情况。异常的具体类型是实现细节,不要把它当成 API 语义的一部分。
indexOf:线性扫描,但 List12 会短路
ListN 的逻辑是标准线性扫描:从下标 0 开始,用 Objects.equals 逐个比较,首个匹配即返回,最坏情况要比较 n 次。
List12 没有循环,直接比较 e0 和 e1,最多 2 次 equals,所以小列表的查找反而更直接。
顺带一个容易踩的点:indexOf(null) 会抛 NullPointerException,而不是返回 -1。List12 里是 Objects.requireNonNull(o);ListN 里是显式判断 if (!allowNulls && o == null) throw new NullPointerException()。List.of 构造出来的列表 allowNulls 恒为 false,所以两者行为一致。
实测(元素为 [1] 的列表)⬇️
indexOf(1) -> 0 (compared 1 of 1)
indexOf(-1) -> -1 (not found)
和常见列表的对照
| 创建方式 | 可变性 | 允许 null | 与源数组的关系 |
|---|---|---|---|
List.of(...) | 完全不可变 | 否,构造时抛 NPE | 自己持有副本或独立数组,源数组改动不影响 |
Arrays.asList(array) | 固定长度,可 set,不可增删 | 是 | 是数组的视图,改原数组会影响列表 |
new ArrayList<>(List.of(...)) | 可变 | 取决于源 | 独立副本 |
List.copyOf(coll) | 完全不可变 | 否 | 入参已是不可变列表时直接复用同一实例 |
List.copyOf 最后一条来自源码 ⬇️
static <E> List<E> listCopy(Collection<? extends E> coll) {
if (coll instanceof List12 || (coll instanceof ListN<?> c && !c.allowNulls)) {
return (List<E>)coll;
} else if (coll.isEmpty()) { // implicit nullcheck of coll
return List.of();
} else {
return (List<E>)List.of(coll.toArray());
}
}
所以 List.copyOf(list) == list 对 List.of 的结果成立(实测为 true),这也是"不可变对象可以安全共享"的一个具体体现。
配套的 GUI 演示程序
上面的结论可以在配套的 Swing 程序里手动复现:ListOfBehaviorDemo.java。它通过反射读取真实对象的私有字段,把"类名 / 字段 / 数组长度 / 越界异常类型"直接画在界面上。
运行方式 ⬇️
javac ListOfBehaviorDemo.java
java --add-opens=java.base/java.util=ALL-UNNAMED ListOfBehaviorDemo
界面分三块 ⬇️
- demo setup:
n =下拉(0、1、2、3、4、5、8、12)+rule =下拉(连续自然数 / 连续偶数 / 连续奇数),下方实时显示将要执行的List.of(...)调用和预测的实现类。 - factory method:一个按钮,文本就是即将执行的调用,例如
List.of(0, 2, 4, 6)。 - index API / element API:
get(i)(下拉里额外提供一项越界下标)、indexOf(v)、add(v)、remove(v)、clear(),用来观察越界异常和 UOE。
成功创建列表后,参数区和工厂按钮会锁定(一次演示只建一个列表),点 Reset 后解锁。右侧上半部分是结构图:List12 画成 e0/e1 两个格子(单元素时 e1 显示为哨兵),ListN 画成下标数组;下半部分是字段形状、运行时信息与操作日志。
n = 1,List12 的 e1 显示为 <EMPTY> 哨兵 ⬇️
n = 4,ListN 的 elements 长度恰好为 4 ⬇️
n = 0,List.of() 命中共享的 EMPTY_LIST ⬇️
越界 get 会触发异常提示 ⬇️
小结
把上面的结论收成一张清单 ⬇️
List.of的实现类是ImmutableCollections$List12或ListN,不是ArrayList。- 0 个元素 → 全局共享的
EMPTY_LIST(本身是ListN,Object[0])。 - 1 个元素 →
List12,e1存EMPTY哨兵,size()不需要计数字段。 - 2 个元素 →
List12,两个final字段,没有数组。 - 3 个及以上 →
ListN,Object[n],长度恰好为 n。 - 3~10 个参数复用调用点的数组;11 个及以上会多复制一份并逐个查 null。
get(i)是 O(1);indexOf是线性扫描(List12最多比较 2 次)。- 构造时拒绝 null;
indexOf(null)也抛 NPE。 - 所有修改方法都抛
UnsupportedOperationException(无 message)。 - 越界异常:
List12抛IndexOutOfBoundsException,ListN抛ArrayIndexOutOfBoundsException,后者是前者的子类。
参考资料 ⬇️
OpenJDK 21源码:java.base/java/util/ImmutableCollections.javaOpenJDK 21源码:java.base/java/util/List.java
配套 GUI 程序的完整代码
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.function.IntFunction;
import java.util.function.IntUnaryOperator;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import javax.swing.ToolTipManager;
import javax.swing.border.Border;
import javax.swing.border.TitledBorder;
/**
* Swing demo of the internals of java.util.List.of(...) on JDK 21. Dispatch on
* argument count: 0 -> shared EMPTY_LIST (ListN, Object[0]); 1 -> List12(e0)
* with e1 = EMPTY sentinel; 2 -> List12(e0, e1); N >= 3 -> ListN, elements =
* Object[N] (exactly N, no padding). A list is an array or two fields, not a
* hash table: get(i) is elements[i] (List12 branches on i), indexOf walks from
* 0 with equals, duplicates are allowed, nulls throw NPE, add/remove/clear
* throw UnsupportedOperationException, and iteration order is insertion order
* on every run (no SALT randomization). Out-of-range get differs by class:
* List12 throws IndexOutOfBoundsException, ListN ArrayIndexOutOfBoundsException.
* <p>
* Fields are private, so reflection is required. Run with:
* javac ListOfBehaviorDemo.java
* java --add-opens=java.base/java.util=ALL-UNNAMED ListOfBehaviorDemo
*/
public class ListOfBehaviorDemo {
// ---- selectable parameters: list size x filling rule ----
/**
* One filling rule: given n it produces the List.of argument list. The
* displayed call text comes from that same Integer[], so it cannot
* disagree with the created list.
*/
private record Rule(String label, String note, IntFunction<Integer[]> gen) {
@Override
public String toString() {
return label;
}
}
/** One entry of the "element" combo box: a member, or an absent probe. */
private record ElementChoice(String label, Integer value) {
@Override
public String toString() {
return label;
}
}
/** One entry of the "index" combo box: a valid index, or one past the end. */
private record IndexChoice(String label, int value) {
@Override
public String toString() {
return label;
}
}
private static final List<Rule> RULES = List.of(
new Rule("consecutive 1..N", "index == value, easy to follow",
n -> seq(n, i -> i + 1)),
new Rule("evens 2i", "values differ from indices, order kept",
n -> seq(n, i -> 2 * i)),
new Rule("odds 2i+1", "same shape as evens, shifted by one",
n -> seq(n, i -> 2 * i + 1)));
/** Sizes offered by the "n =" combo; n <= 2 still yields List12. */
private static final List<Integer> SIZES = List.of(0, 1, 2, 3, 4, 5, 8, 12);
/** The absent probe appended to the element combo box; all rules yield non-negative values. */
private static final Integer ABSENT_VALUE = -1;
/** n boxed values built by f(i); every rule with a closed form goes through here. */
private static Integer[] seq(int n, IntUnaryOperator f) {
Integer[] v = new Integer[n];
for (int i = 0; i < n; i++) {
v[i] = f.applyAsInt(i);
}
return v;
}
// ---- shared design language with SetOfBehaviorDemo ----
/** Blue for buttons that map 1:1 to Java code (IDE syntax-highlight blue). */
private static final Color CODE_COLOR = new Color(21, 101, 192);
/** Dark gray for demo helper buttons: clearly secondary, not disabled. */
private static final Color DEMO_COLOR = new Color(93, 93, 93);
/** Light gray used when a button is disabled. */
private static final Color DISABLED_COLOR = new Color(175, 175, 175);
private static final Color SLOT_FILLED_COLOR = new Color(66, 133, 244);
private static final Color SENTINEL_BG = new Color(255, 248, 225);
private static final Color SENTINEL_FG = new Color(158, 101, 0);
private static final Color LAST_TOUCHED_COLOR = new Color(244, 180, 0);
/** Error feedback: light red banner background, dark red text/border. */
private static final Color ERROR_BG = new Color(253, 237, 237);
private static final Color ERROR_FG = new Color(183, 28, 28);
private static final Color ERROR_LINE = new Color(239, 83, 80);
/** Red-outlined variant of the plain etched box, used for the throwing input. */
private static final Border ERROR_BOX = BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(ERROR_LINE, 2),
BorderFactory.createEmptyBorder(2, 2, 2, 2));
private static final Border PLAIN_BOX = BorderFactory.createEtchedBorder();
private static final Font CODE_FONT = new Font(Font.MONOSPACED, Font.BOLD, 13);
// ---- state ----
/** null means "no list yet": element operations stay disabled until List.of runs. */
private List<Integer> list;
/** Index highlighted as the result of the most recent successful get / find. */
private int lastTouchedSlot = -1;
/** Indices compared by the most recent indexOf, in visiting order. */
private List<Integer> probePath = List.of();
/** Everything the view needs about the current object; grid and text panel share it. */
private List<Slot> slots = List.of();
private boolean slotsFromList12;
private boolean emptySharedList;
/** One drawn cell: a ListN array slot, or a List12 field (e0 / e1). */
private record Slot(int index, String title, String value, String note,
boolean sentinel) {
}
// ---- UI ----
private JButton createButton;
private JButton getButton;
private JButton indexOfButton;
private JButton addButton;
private JButton removeButton;
private JButton clearButton;
private JButton resetButton;
private JComboBox<Integer> sizeCombo;
private JComboBox<Rule> ruleCombo;
private JComboBox<IndexChoice> indexCombo;
private JComboBox<ElementChoice> elementCombo;
private JLabel glossLabel;
private JLabel errorBanner;
/** The size + rule selector box that turns red when the call throws. */
private JPanel presetGroupPanel;
/** Set when JDK 9+ module rules deny reflection: the banner then stays up. */
private boolean reflectionBlind;
private JLabel runtimeClassLabel;
private JLabel sizeLabel;
private JLabel storageLabel;
private JLabel accessLabel;
private JLabel iterationLabel;
private JLabel toStringLabel;
private JPanel statePanel;
private JTextArea fieldShapeArea;
private JTextArea logArea;
private SlotGrid slotGrid;
public static void main(String[] args) {
SwingUtilities.invokeLater(ListOfBehaviorDemo::new);
}
private ListOfBehaviorDemo() {
createAndShowGui();
}
private void createAndShowGui() {
// Snappier, longer-lived tooltips: the defaults (750 ms to appear,
// 4 s auto-dismiss) make a mapping table this long feel unreliable.
ToolTipManager.sharedInstance().setInitialDelay(300);
ToolTipManager.sharedInstance().setDismissDelay(12000);
JFrame frame = new JFrame("List.of internals demo");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setLayout(new BorderLayout());
JPanel control = new JPanel();
control.setLayout(new BoxLayout(control, BoxLayout.Y_AXIS));
control.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
// ---------- row 0: which List.of call to demo ----------
sizeCombo = new JComboBox<>();
for (Integer n : SIZES) {
sizeCombo.addItem(n);
}
sizeCombo.setSelectedItem(4);
sizeCombo.setToolTipText("Number of arguments passed to List.of(...)");
ruleCombo = new JComboBox<>();
for (Rule r : RULES) {
ruleCombo.addItem(r);
}
ruleCombo.setToolTipText("How the Integer arguments are chosen");
glossLabel = new JLabel();
presetGroupPanel = new JPanel();
presetGroupPanel.setBorder(BorderFactory.createEtchedBorder());
presetGroupPanel.add(new JLabel("n ="));
presetGroupPanel.add(sizeCombo);
presetGroupPanel.add(new JLabel("rule ="));
presetGroupPanel.add(ruleCombo);
presetGroupPanel.add(glossLabel);
control.add(row("demo setup", DEMO_COLOR, presetGroupPanel));
// ---------- row 1: the factory method ----------
createButton = new JButton();
createButton.setFont(CODE_FONT);
control.add(row("factory method", CODE_COLOR, createButton));
// ---------- row 2: index-based access ----------
getButton = new JButton();
getButton.setFont(CODE_FONT);
indexCombo = new JComboBox<>();
indexCombo.setToolTipText("Index passed to get(int)");
indexCombo.addActionListener(e -> updateOpButtonText());
JPanel indexGroup = new JPanel();
indexGroup.setBorder(BorderFactory.createEtchedBorder());
indexGroup.add(new JLabel("index ="));
indexGroup.add(indexCombo);
control.add(row("index API", CODE_COLOR, indexGroup, getButton));
// ---------- row 3: element-based operations ----------
indexOfButton = new JButton();
addButton = new JButton();
removeButton = new JButton();
clearButton = new JButton("clear()");
for (JButton b : new JButton[]{indexOfButton, addButton, removeButton, clearButton}) {
b.setFont(CODE_FONT);
}
clearButton.setToolTipText("Runs: list.clear() (immutable lists always throw)");
elementCombo = new JComboBox<>();
elementCombo.setToolTipText("Picks the argument for indexOf / add / remove");
elementCombo.addActionListener(e -> updateOpButtonText());
JPanel selGroup = new JPanel();
selGroup.setBorder(BorderFactory.createEtchedBorder());
selGroup.add(new JLabel("element ="));
selGroup.add(elementCombo);
control.add(row("element API", CODE_COLOR, selGroup, indexOfButton, addButton,
removeButton, clearButton));
// ---------- row 4: demo-only helpers ----------
resetButton = new JButton("Reset");
resetButton.setToolTipText("Demo control, not a Java API call: returns to the initial state");
control.add(row("demo only", DEMO_COLOR, resetButton));
// ---------- error banner: hidden until a call throws ----------
errorBanner = new JLabel();
errorBanner.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
errorBanner.setForeground(ERROR_FG);
errorBanner.setOpaque(true);
errorBanner.setBackground(ERROR_BG);
errorBanner.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(ERROR_LINE),
BorderFactory.createEmptyBorder(4, 8, 4, 8)));
errorBanner.setVisible(false);
JPanel bannerRow = new JPanel(new BorderLayout());
bannerRow.setAlignmentX(Component.LEFT_ALIGNMENT);
bannerRow.add(errorBanner, BorderLayout.CENTER);
control.add(bannerRow);
// ---------- state panel: one labeled line per internal fact ----------
statePanel = new JPanel();
statePanel.setLayout(new BoxLayout(statePanel, BoxLayout.Y_AXIS));
statePanel.setAlignmentX(Component.LEFT_ALIGNMENT);
statePanel.setBorder(new TitledBorder("internal state of the returned list"));
runtimeClassLabel = new JLabel();
sizeLabel = new JLabel();
storageLabel = new JLabel();
accessLabel = new JLabel();
iterationLabel = new JLabel();
toStringLabel = new JLabel();
for (JLabel line : new JLabel[]{runtimeClassLabel, sizeLabel, storageLabel,
accessLabel, iterationLabel, toStringLabel}) {
line.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 2));
row.setAlignmentX(Component.LEFT_ALIGNMENT);
row.add(line);
statePanel.add(row);
}
slotGrid = new SlotGrid();
statePanel.add(slotGrid);
// ---------- field shape panel ----------
// Prints only what the current object actually holds, read via reflection.
fieldShapeArea = new JTextArea();
fieldShapeArea.setEditable(false);
fieldShapeArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
JScrollPane fieldScroll = new JScrollPane(fieldShapeArea);
fieldScroll.setBorder(new TitledBorder("fields (read from the actual object)"));
// Side by side: the live slots on the left, the textual explanation on
// the right. A split pane instead of BorderLayout: with WEST + CENTER,
// the text panel silently absorbed every width deficit and could
// collapse to a sliver whenever the window got narrower than the
// fixed-width slot grid. resizeWeight keeps both sides usable.
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, statePanel, fieldScroll);
split.setBorder(null);
split.setContinuousLayout(true);
split.setOneTouchExpandable(true);
split.setResizeWeight(0.66);
// ---------- operation log ----------
logArea = new JTextArea();
logArea.setEditable(false);
logArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
JScrollPane logScroll = new JScrollPane(logArea);
logScroll.setBorder(new TitledBorder("Operation log"));
logScroll.setPreferredSize(new Dimension(0, 180));
frame.add(control, BorderLayout.NORTH);
frame.add(split, BorderLayout.CENTER);
frame.add(logScroll, BorderLayout.SOUTH);
// ---------- listeners ----------
sizeCombo.addActionListener(e -> onPresetChanged());
ruleCombo.addActionListener(e -> onPresetChanged());
createButton.addActionListener(e -> doCreate());
getButton.addActionListener(e -> doGet());
indexOfButton.addActionListener(e -> doIndexOf());
addButton.addActionListener(e -> doAdd());
removeButton.addActionListener(e -> doRemove());
clearButton.addActionListener(e -> doClear());
resetButton.addActionListener(e -> resetDemo());
reflectionBlind = !canReadInternals();
onPresetChanged();
setNoListState();
printStartupLog();
refreshView();
if (reflectionBlind) {
showAccessError();
}
frame.setSize(1320, 800);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
// proportional divider position needs the final component size
split.setDividerLocation(0.64);
}
// ---- operations ----
private void doCreate() {
clearError();
Integer[] elements = currentElements();
String call = callText(elements);
List<Integer> created;
try {
created = List.of(elements);
} catch (RuntimeException ex) {
// Nulls are rejected before the list is built, so the previous
// list (if any) stays on screen.
String detail = exceptionText(ex);
logLine(String.format("%-56s threw %s", call, detail));
showError(call + " -> " + detail);
refreshView();
return;
}
list = created;
lastTouchedSlot = -1;
probePath = List.of();
rebuildChoices(created);
rebuildIndices(created.size());
setListCreatedState();
logLine(String.format("%-56s %s", call, created.size() <= 2
? created.getClass().getSimpleName() + ", no array"
: "ListN, size " + created.size() + ", elements length " + created.size()));
refreshView();
}
private void doGet() {
clearError();
IndexChoice index = selectedIndexChoice();
String call = "get(" + index.value() + ")";
lastTouchedSlot = -1;
probePath = List.of();
try {
Integer value = list.get(index.value());
lastTouchedSlot = index.value();
logLine(String.format("%-56s %s", call, format(value)));
} catch (RuntimeException ex) {
// List12 checks the index itself; ListN lets the array do it.
String detail = exceptionText(ex);
logLine(String.format("%-56s threw %s", call, detail));
showError(call + " -> " + detail);
}
refreshView();
}
private void doIndexOf() {
clearError();
ElementChoice c = selectedChoice();
String call = "indexOf(" + format(c.value()) + ")";
int size = list.size();
List<Integer> visited = new ArrayList<>();
int hit = -1;
for (int i = 0; i < size; i++) {
visited.add(i);
if (list.get(i).equals(c.value())) {
hit = i;
break;
}
}
probePath = visited;
lastTouchedSlot = hit;
String detail = (hit < 0 ? "-> -1 (not found)" : "-> " + hit)
+ " (" + (visited.isEmpty() ? "empty list" : "compared "
+ visited.size() + " of " + size) + ")";
logLine(String.format("%-56s %s", call, detail));
refreshView();
}
private void doAdd() {
clearError();
ElementChoice c = selectedChoice();
String call = "add(" + format(c.value()) + ")";
lastTouchedSlot = -1;
probePath = List.of();
try {
boolean changed = list.add(c.value());
// Unreachable on JDK immutable lists; logged honestly if it ever is.
logLine(String.format("%-56s returned %s (unexpected)", call, changed));
} catch (UnsupportedOperationException ex) {
String detail = exceptionText(ex);
logLine(String.format("%-56s threw %s", call, detail));
showError(call + " -> " + detail);
}
refreshView();
}
private void doRemove() {
clearError();
ElementChoice c = selectedChoice();
String call = "remove(" + format(c.value()) + ")";
lastTouchedSlot = -1;
probePath = List.of();
try {
boolean changed = list.remove(c.value());
logLine(String.format("%-56s returned %s (unexpected)", call, changed));
} catch (UnsupportedOperationException ex) {
String detail = exceptionText(ex);
logLine(String.format("%-56s threw %s", call, detail));
showError(call + " -> " + detail);
}
refreshView();
}
private void doClear() {
clearError();
lastTouchedSlot = -1;
probePath = List.of();
try {
list.clear();
logLine(String.format("%-56s returned void (unexpected)", "clear()"));
} catch (UnsupportedOperationException ex) {
String detail = exceptionText(ex);
logLine(String.format("%-56s threw %s", "clear()", detail));
showError("clear() -> " + detail);
}
refreshView();
}
private void resetDemo() {
list = null;
lastTouchedSlot = -1;
probePath = List.of();
slots = List.of();
slotsFromList12 = false;
emptySharedList = false;
elementCombo.removeAllItems();
indexCombo.removeAllItems();
setNoListState();
clearError();
logArea.setText("");
printStartupLog();
refreshView();
}
private static String exceptionText(RuntimeException ex) {
return ex.getClass().getName()
+ (ex.getMessage() == null ? " (no message)" : ": " + ex.getMessage());
}
/** Cheap startup preflight: no list instance is needed to test module access. */
private static boolean canReadInternals() {
try {
Class.forName("java.util.ImmutableCollections$ListN")
.getDeclaredField("elements").setAccessible(true);
return true;
} catch (Throwable t) {
return false;
}
}
/** Every panel reads private fields, so block the UI instead of half-updating it. */
private void showAccessError() {
resetButton.setEnabled(false);
setNoListState();
errorBanner.setText("<html><b>Reflection access denied</b> — this demo reads "
+ "ImmutableCollections$ListN.elements.<br>Restart with <code>java --add-opens="
+ "java.base/java.util=ALL-UNNAMED ListOfBehaviorDemo</code></html>");
errorBanner.setVisible(true);
}
/** Banner plus a red border on the n / rule box; cleared by the next success. */
private void showError(String message) {
errorBanner.setText(message);
errorBanner.setVisible(true);
presetGroupBorder(true);
}
private void clearError() {
if (reflectionBlind) {
return; // keep the access banner up: no panel can work below it
}
errorBanner.setVisible(false);
presetGroupBorder(false);
}
/** The n / rule pair is the only input that can throw, so it is the red one. */
private void presetGroupBorder(boolean error) {
presetGroupPanel.setBorder(error ? ERROR_BOX : PLAIN_BOX);
}
/** Re-derives the call text, gloss and preview whenever n or the rule changes. */
private void onPresetChanged() {
clearError();
Rule rule = currentRule();
// With zero arguments every rule produces the same empty list.
ruleCombo.setEnabled(currentSize() > 0);
Integer[] elements = currentElements();
glossLabel.setText(currentSize() + " element(s)"
+ " | " + shapeOf(elements.length)
+ " | " + rule.note());
String call = callText(elements);
createButton.setText(call);
createButton.setToolTipText("Runs: " + call);
updateOpButtonText();
refreshView();
}
private static String shapeOf(int n) {
if (n == 0) {
return "shared EMPTY_LIST (ListN, Object[0])";
}
if (n <= 2) {
return "List12, two final fields (no array)";
}
return "ListN, elements length " + n + (n > 10 ? " (varargs overload)" : "");
}
/** Members in insertion order plus one absent probe; 0 is never a member. */
private void rebuildChoices(List<Integer> created) {
elementCombo.removeAllItems();
for (Integer value : created) {
elementCombo.addItem(new ElementChoice(format(value), value));
}
elementCombo.addItem(new ElementChoice(format(ABSENT_VALUE) + " (absent)", ABSENT_VALUE));
}
/** Valid indices plus one past the end, so get() can also show a failure. */
private void rebuildIndices(int size) {
indexCombo.removeAllItems();
for (int i = 0; i < size; i++) {
indexCombo.addItem(new IndexChoice(String.valueOf(i), i));
}
indexCombo.addItem(new IndexChoice(size + " (out of range)", size));
updateOpButtonText();
}
private void updateOpButtonText() {
IndexChoice index = selectedIndexChoice();
getButton.setText(index == null ? "get(i)" : "get(" + index.value() + ")");
ElementChoice c = selectedChoice();
if (c == null) {
return;
}
String v = format(c.value());
indexOfButton.setText("indexOf(" + v + ")");
addButton.setText("add(" + v + ")");
removeButton.setText("remove(" + v + ")");
}
// ---- button state machine (same pattern as SetOfBehaviorDemo) ----
private void setNoListState() {
enableOps(false);
enablePreset(!reflectionBlind);
refreshButtonColors();
}
private void setListCreatedState() {
enableOps(true);
enablePreset(false);
refreshButtonColors();
}
/** Demo setup and the factory button are one-shot: locked once a list exists. */
private void enablePreset(boolean on) {
sizeCombo.setEnabled(on);
ruleCombo.setEnabled(on && currentSize() > 0);
createButton.setEnabled(on);
}
/** One flag drives the whole ops rows; Reset is demo-only and stays available. */
private void enableOps(boolean on) {
indexCombo.setEnabled(on);
elementCombo.setEnabled(on);
for (JButton b : new JButton[]{getButton, indexOfButton, addButton, removeButton,
clearButton}) {
b.setEnabled(on);
}
refreshButtonColors();
}
/** Java-mapped buttons are blue, demo helpers dark gray, disabled turns light gray. */
private void refreshButtonColors() {
for (JButton b : new JButton[]{createButton, getButton, indexOfButton, addButton,
removeButton, clearButton}) {
b.setForeground(b.isEnabled() ? CODE_COLOR : DISABLED_COLOR);
}
resetButton.setForeground(resetButton.isEnabled() ? DEMO_COLOR : DISABLED_COLOR);
}
// ---- reflection access to java.util.ImmutableCollections ----
/** Reads one private field of the created object; the only reflective call. */
private static Object readField(Object target, String name) {
try {
Field f = target.getClass().getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
} catch (java.lang.reflect.InaccessibleObjectException e) {
throw new IllegalStateException(
"cannot open java.base/java.util; run with "
+ "--add-opens=java.base/java.util=ALL-UNNAMED", e);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("cannot read " + name + " on "
+ target.getClass().getName(), e);
}
}
private Integer[] readElements() {
Object raw = readField(list, "elements");
Object[] elements = (Object[]) raw;
Integer[] values = new Integer[elements.length];
for (int i = 0; i < elements.length; i++) {
values[i] = (Integer) elements[i];
}
return values;
}
// ---- view refresh ----
private void refreshView() {
if (list == null) {
runtimeClassLabel.setText(stateLine("runtime class", "--"));
sizeLabel.setText(stateLine("size", "-- (call List.of(...) first)"));
storageLabel.setText(stateLine("storage", "--"));
accessLabel.setText(stateLine("get(i)", "--"));
iterationLabel.setText(stateLine("iteration", "--"));
toStringLabel.setText(stateLine("toString()", "--"));
slots = List.of();
slotGrid.updateForSlotCount(0);
fieldShapeArea.setText(noListText());
slotGrid.repaint();
return;
}
boolean list12 = list.getClass().getSimpleName().endsWith("List12");
slotsFromList12 = list12;
if (list12) {
slots = buildList12Slots();
emptySharedList = false;
runtimeClassLabel.setText(stateLine("runtime class", list.getClass().getName()));
sizeLabel.setText(stateLine("size", String.valueOf(list.size())
+ (list.size() == 1 ? " (e1 == EMPTY, no count field)" : "")));
storageLabel.setText(stateLine("storage", "final Object e0, e1 (no array)"));
accessLabel.setText(stateLine("get(i)", "e0 / e1 branch, O(1)"));
} else {
Integer[] elements = readElements();
Boolean allowNulls = (Boolean) readField(list, "allowNulls");
emptySharedList = elements.length == 0;
slots = buildListNSlots(elements);
runtimeClassLabel.setText(stateLine("runtime class", list.getClass().getName()));
sizeLabel.setText(stateLine("size", String.valueOf(list.size())));
storageLabel.setText(stateLine("storage", "Object[] elements, length "
+ elements.length + ", allowNulls = " + allowNulls));
accessLabel.setText(stateLine("get(i)", "elements[i], O(1), no probing"));
}
iterationLabel.setText(stateLine("iteration",
"insertion order, deterministic"
+ (emptySharedList ? " | shared EMPTY_LIST" : "")));
toStringLabel.setText(stateLine("toString()", list.toString()));
fieldShapeArea.setText(buildFieldsText());
fieldShapeArea.setCaretPosition(0);
slotGrid.updateForSlotCount(slots.size());
slotGrid.repaint();
}
/** List12 keeps two final fields; a one-element list stores the EMPTY sentinel in e1. */
private List<Slot> buildList12Slots() {
Integer e0 = (Integer) readField(list, "e0");
Object rawE1 = readField(list, "e1");
boolean sentinel = list.size() == 1;
String e1Value = sentinel ? "<EMPTY>" : format((Integer) rawE1);
return List.of(
new Slot(0, "e0", format(e0), "index 0", false),
new Slot(1, "e1", e1Value,
sentinel ? "shared sentinel\nsize() == 1" : "index 1",
sentinel));
}
private static List<Slot> buildListNSlots(Integer[] elements) {
List<Slot> result = new ArrayList<>();
for (int i = 0; i < elements.length; i++) {
result.add(new Slot(i, "[" + i + "]", format(elements[i]),
"elements[" + i + "]", false));
}
return result;
}
private String buildFieldsText() {
StringBuilder sb = new StringBuilder();
sb.append(list.getClass().getName()).append('\n');
if (slotsFromList12) {
sb.append(" private final Object e0; // ").append(slots.get(0).value()).append('\n');
sb.append(" private final Object e1; // ").append(slots.get(1).value()).append('\n');
sb.append(" // no array: size() is e1 != EMPTY ? 2 : 1, so no count field\n")
.append(" // get(0) reads e0, get(1) reads e1 and fails when e1 is EMPTY\n");
return sb.toString();
}
int size = slots.size();
sb.append(" private final Object[] elements; // length ").append(size).append('\n');
sb.append(" private final boolean allowNulls; // false: List.of rejects nulls\n");
sb.append('\n');
if (size == 0) {
sb.append(" // List.of() returns the shared ImmutableCollections.EMPTY_LIST:\n")
.append(" // List.of() == List.of() is ")
.append(List.of() == List.of()).append('\n');
return sb.toString();
}
sb.append(" // elements[i] is list.get(i): index order == insertion order\n");
return sb.toString();
}
private static String noListText() {
return "nothing created yet\n\nclick List.of(...) above: this panel then prints"
+ " the fields read from the actual object.\n";
}
private void printStartupLog() {
logLine("what to observe:");
logLine(" List.of() -> shared EMPTY_LIST: a ListN with elements = Object[0]");
logLine(" List.of(e1) -> List12 with e1 = EMPTY, so size() needs no count field");
logLine(" List.of(e1, e2) -> List12 with two final fields, no array");
logLine(" List.of(e1..eN) -> ListN with elements = new Object[N] (exactly N, no padding)");
logLine(" get(i) -> elements[i]; List12 branches on i, no probing");
logLine(" indexOf(v) -> linear scan from 0 with equals; first match wins");
logLine(" null elements -> List.of rejects them with NullPointerException");
logLine(" out of range -> List12: IndexOutOfBoundsException, ListN: ArrayIndexOutOfBoundsException");
logLine(" add/remove/clear -> UnsupportedOperationException: the list is immutable");
logLine(" iteration order -> insertion order, identical on every run (no SALT)");
logLine(" reflection -> needs --add-opens=java.base/java.util=ALL-UNNAMED");
logLine("");
}
/** Uniform "key = value" layout; keys padded so the '=' column aligns. */
private static String stateLine(String key, String value) {
return String.format("%-15s= %s", key, value);
}
private void logLine(String line) {
logArea.append(line + System.lineSeparator());
logArea.setCaretPosition(logArea.getDocument().getLength());
}
/** Left-hand caption of a button row; fixed width keeps rows aligned. */
private static JLabel rowCaption(String text, Color color) {
JLabel label = new JLabel(text);
label.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 11));
label.setForeground(color);
label.setPreferredSize(new Dimension(96, 18));
return label;
}
private static JPanel row(String caption, Color color, Component... items) {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT));
p.setAlignmentX(Component.LEFT_ALIGNMENT);
p.add(rowCaption(caption, color));
for (Component c : items) {
p.add(c);
}
return p;
}
private Rule currentRule() {
return (Rule) ruleCombo.getSelectedItem();
}
private int currentSize() {
Integer n = (Integer) sizeCombo.getSelectedItem();
return n == null ? 0 : n;
}
private Integer[] currentElements() {
return currentRule().gen().apply(currentSize());
}
private IndexChoice selectedIndexChoice() {
return (IndexChoice) indexCombo.getSelectedItem();
}
private ElementChoice selectedChoice() {
return (ElementChoice) elementCombo.getSelectedItem();
}
private static String callText(Integer[] elements) {
if (elements.length == 0) {
return "List.of()";
}
StringBuilder sb = new StringBuilder("List.of(");
for (int i = 0; i < elements.length; i++) {
if (i > 0) {
sb.append(", ");
}
sb.append(format(elements[i]));
}
return sb.append(')').toString();
}
/** Integers print as plain digits; null stays null, which makes List.of throw. */
private static String format(Integer value) {
return value == null ? "null" : value.toString();
}
// ---- slot grid: one cell per ListN array slot, or per List12 field ----
/**
* Index order (ListN array) or field order (List12 e0/e1). All cells are
* filled, the last hit index is orange, and indexOf-compared cells get a
* dashed amber border.
*/
private final class SlotGrid extends JPanel {
private static final int CELL_W = 96;
private static final int CELL_H = 78;
private static final int GAP = 8;
private static final int PER_ROW = 8;
/** Fixed panel width keeps the state panel stable across presets. */
private static final int GRID_W = 830;
SlotGrid() {
updateForSlotCount(0);
setBackground(Color.WHITE);
}
/** Grows the panel to as many rows as the current storage needs. */
void updateForSlotCount(int count) {
int rows = Math.max(1, (count + PER_ROW - 1) / PER_ROW);
setPreferredSize(new Dimension(GRID_W, rows * (CELL_H + GAP) + GAP));
revalidate();
}
@Override
protected void paintComponent(Graphics g0) {
super.paintComponent(g0);
Graphics2D g = (Graphics2D) g0;
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
if (slots.isEmpty()) {
g.setColor(Color.GRAY);
g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
g.drawString(list == null
? "no list yet - click List.of(...) above"
: "elements = Object[0]: the shared EMPTY_LIST has no slots", 16, 28);
return;
}
int cols = Math.min(slots.size(), PER_ROW);
int rows = (slots.size() + PER_ROW - 1) / PER_ROW;
int totalW = cols * CELL_W + (cols - 1) * GAP;
int startX = Math.max(GAP, (getWidth() - totalW) / 2);
for (int i = 0; i < slots.size(); i++) {
int row = i / PER_ROW;
int col = i % PER_ROW;
int x = startX + col * (CELL_W + GAP);
int y = GAP + row * (CELL_H + GAP);
paintSlot(g, slots.get(i), x, y);
}
}
private void paintSlot(Graphics2D g, Slot slot, int x, int y) {
boolean onPath = probePath.contains(slot.index());
boolean touched = slot.index() == lastTouchedSlot;
g.setColor(slot.sentinel() ? SENTINEL_BG : SLOT_FILLED_COLOR);
g.fillRect(x, y, CELL_W, CELL_H);
if (touched) {
g.setColor(LAST_TOUCHED_COLOR);
g.setStroke(new BasicStroke(3));
} else if (onPath) {
g.setColor(LAST_TOUCHED_COLOR);
g.setStroke(new BasicStroke(2, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
10f, new float[]{6f, 4f}, 0f));
} else {
g.setColor(new Color(180, 180, 180));
g.setStroke(new BasicStroke(1));
}
g.drawRect(x, y, CELL_W, CELL_H);
// slot label: [i] for array slots, e0 / e1 for List12 fields
g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 10));
g.setColor(slot.sentinel() ? SENTINEL_FG : Color.WHITE);
g.drawString(slot.title(), x + 6, y + 14);
// element
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
FontMetrics fm = g.getFontMetrics();
String text = ellipsize(slot.value(), fm, CELL_W - 12);
g.setColor(slot.sentinel() ? SENTINEL_FG : Color.WHITE);
g.drawString(text, x + (CELL_W - fm.stringWidth(text)) / 2, y + 44);
// note: the field expression / logical index, one line per fact
if (slot.note() != null && !slot.note().isEmpty()) {
g.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 9));
g.setColor(slot.sentinel() ? SENTINEL_FG : Color.WHITE);
FontMetrics nfm = g.getFontMetrics();
String[] lines = slot.note().split("\n");
int baseline = y + (lines.length > 1 ? 56 : 66);
for (int i = 0; i < lines.length; i++) {
String line = ellipsize(lines[i], nfm, CELL_W - 10);
g.drawString(line, x + (CELL_W - nfm.stringWidth(line)) / 2, baseline + i * 12);
}
}
}
private static String ellipsize(String text, FontMetrics fm, int maxWidth) {
if (fm.stringWidth(text) <= maxWidth) {
return text;
}
String cut = text;
while (cut.length() > 1 && fm.stringWidth(cut + "..") > maxWidth) {
cut = cut.substring(0, cut.length() - 1);
}
return cut + "..";
}
}
}