Teyru

Lombok 相容層

編譯器內建的 Lombok 相容層:標註在語意分析階段展開成一般的 Teyru 成員,不需要 annotation processor。

Teyru 的編譯器內建 Lombok 相容層:標註(annotation)會被解析成帶參數的語法樹, 再於語意分析階段展開成一般的 Teyru 成員,之後與手寫程式碼走完全相同的 型別檢查與程式碼產生路徑。不需要 annotation processor,不需要 javac,也不會有 AST 注入。

import lombok.*

@Data
@AllArgsConstructor
@Builder
class Person {
  private String name
  private int age
}

Person p = Person.builder().name("ada").age(36).build()
System.out.println(p.getName())        // ada
System.out.println(p)                  // Person(name=ada, age=36)

使用方式:import lombok.X(或直接寫 @lombok.X)。匯入只是為了可讀性, 編譯器以註解的簡單名稱比對,所以 @Data、@lombok.Data、 @lombok.experimental.UtilityClass 都認得。


1. 支援狀態總表

註解狀態說明
@Getter✅ 完整含 AccessLevel(Lombok 的參數名是 value:位置形式與 value = AccessLevel.X 都讀)、@Accessors 影響命名;lazy = true 在第一次讀取時算一次並快取(原生型別也支援),雙重檢查,與 Lombok 同樣執行緒安全
@Setter✅ 完整含 AccessLevel(Lombok 的參數名是 value:位置形式與 value = AccessLevel.X 都讀)、@Accessors(chain)、@NonNull 欄位的檢查;名稱已經被佔用時不產生(Lombok 同)
@ToString✅ 完整of/exclude/callSuper/includeFieldNames/onlyExplicitlyIncluded(搭配欄位上的 @ToString.Include/@ToString.Exclude);callSuper 的 super 那一段寫在欄位前面,與 Lombok 相同
@EqualsAndHashCode✅ 完整of/exclude/callSuper/onlyExplicitlyIncluded(@EqualsAndHashCode.Include/@EqualsAndHashCode.Exclude);hashCode 的常數(59/43/79/97)、欄位順序(原生型別,再 boxed 原生型別,再其餘)與 canEqual 都與 Lombok 相同
@NoArgsConstructor✅ 完整staticName 會產生靜態工廠;access(Lombok 的參數名就叫 access:位置形式與 access = AccessLevel.X 都讀)
@RequiredArgsConstructor✅ 完整final(無初始值)與 @NonNull 欄位
@AllArgsConstructor✅ 完整略過已有初始值的 final 欄位
@Data⚠️ 部分getter + setter + @RequiredArgsConstructor + @ToString + @EqualsAndHashCode;隱含建構子收 @NonNull 欄位並在裡面插檢查
@Value⚠️ 部分private final 欄位、getter、全參數建構子、staticConstructor;繼承會被 TY-TYP-0007 擋下
@Builder⚠️ 部分類別、建構子與方法;builderMethodName/buildMethodName/builderClassName/toBuilder/@Builder.Default/@Builder.ObtainVia/setterPrefix(首字母會大寫:with 加 name 是 withName)
@NonNull⚠️ 部分欄位與參數都檢查:欄位被收進產生的建構子時插檢查,@Setter 產生的 setter 也檢查,手寫方法與建構子的參數(只標在參數上即可)同樣檢查。直接指派欄位不檢查(Lombok 也一樣);@Builder 的檢查位置與 Lombok 不同(見 §3)
@With⚠️ 部分欄位上的 @With 產生 withX(T),以全參數建構子複製;寫在類別上不會替所有欄位產生(Lombok 會)
@Accessors✅ 完整chain/fluent/prefix;fluent = true 會連帶把 setter 變成可鏈式,寫出來的 chain 仍然自己決定,靜態欄位的 setter 一律 void(都與 Lombok 相同)
@FieldDefaults✅ 完整level/makeFinal
@UtilityClass⚠️ 部分建構子 private、成員 static;繼承會被 TY-TYP-0007 擋下
@StandardException⚠️ 部分產生 4 個標準例外界建構子;E(Throwable) 用 cause.getMessage() 當訊息。差異:全參數建構子是 super(message, cause),Lombok 是 super(message) 加 initCause(cause)
@Cleanup✅ 完整展開為 try-with-resources,任何離開路徑都會 close
@SneakyThrows✅ 完整展開為 try/catch(Throwable) 後重拋
@Synchronized✅ 完整方法本體包進 synchronized;靜態方法用產生的 __lock$<類別名> 欄位
@Log 家族✅ 完整@Log/@Slf4j/@Log4j/@Log4j2/@CommonsLog/@JBossLog/@Flogger/@XSlf4j 都產生 private static final Logger log(見 §5)
@ExtensionMethod✅ 完整找不到方法時改寫為 Ext.method(receiver, ...)
@FieldNameConstants⚠️ 部分產生巢狀 Fields 類別;prefix 是加在常數的值上,不是加在名稱上,與 Lombok(1.18.4 以前)相反
@Delegate✅ 完整為欄位型別的公開方法產生委派方法
@Helper✅ 完整方法內的區域類別:產生實例,宣告之後同名的未限定呼叫都走它(實例必須有無參數建構子)
@Tolerate✅ 完整被標的成員對產生器「不存在」:@Setter private Instant date 加上 @Tolerate public void setDate(String) 會同時有兩個多載
@Locked✅ 完整以具名鎖欄位包住方法本體
@NonFinal⚠️ 部分收得下註解,但沒有任何作用:Lombok 用它讓 @FieldDefaults(makeFinal = true)/@Value 放過一個欄位,這裡不讀;標在類別上時,繼承檢查在更早的階段就已經跑過了
@PackagePrivate⚠️ 部分只有寫在類別上才有效,把該類別欄位與方法的存取修飾符拿掉;寫在欄位或方法上(Lombok 的用法:讓 @FieldDefaults(level = …)/@Value 放過一個欄位)沒有作用
@Var✅ 完整已棄用的 Lombok 別名,無需產生任何東西
@SuperBuilder✅ 完整建構子鏈上的所有欄位都在同一個 builder;見 §4
@Singular✅ 完整逐項加入、整批加入、清除、build() 取得副本;@Singular("name") 可改名;見 §4
@Jacksonized❌ 不適用沒有 Jackson,註解被接受但不產生任何東西
@Builder.ObtainVia✅ 完整field/method/isStatic,由 toBuilder 讀取——與 Lombok 相同,build() 讀的是 builder 自己的欄位
@onMethod_/@onParam_/@onConstructor_✅ 完整註解被複製到產生的 getter/setter 參數/建構子上(見 §3.5)
@CustomLog✅ 完整讀 lombok.config 的 lombok.log.custom.declaration(見 §3.5)

「完整」的定義:tests/programs/t16–t19、t54 有對應的測試,go test ./... 會驗證輸出; t55_lombok_every.teyru 在一支程式裡把上表每一個 ✅ 的註解各用一次(只有 @CustomLog 與 @onX 家族不在裡面,見下一句),輸出逐行比對; t91_lombok_log.teyru 涵蓋 @Log、@CustomLog(含 lombok.config)與 @onX 家族; t144_lombok_parity.teyru 涵蓋 @NonNull 的各條路徑、@Tolerate、 onlyExplicitlyIncluded、setterPrefix、方法上的 @Builder、@Builder.ObtainVia、 @Helper、@Getter(lazy = true) 與 @StandardException。 標 ⚠️ 的是「收得下註解、但行為與 Lombok 有落差」的列。

這張表是逐條寫程式、拿 Lombok 的說明與原始碼對過之後才標的:@Builder.ObtainVia 以前在 build() 裡被讀取(Lombok 只在 toBuilder 讀它),@Helper 與 @Tolerate 以前只是收下註解,@Getter(lazy = true) 的初始值會被算兩次且原生型別編譯不過, @Data 的建構子不收 @NonNull 欄位,@Setter 產生的 setter 不插檢查, @Value/@UtilityClass 的 final 攔不住繼承,@StandardException 的 E(Throwable) 不帶訊息——這些都已照 Lombok 的行為修掉,並且各有測試。


2. 產生的成員長什麼樣子

以 @Data class Person { private String name; private int age } 為例, 展開後等同於:

class Person {
  private String name
  private int age

  public Person() {
  }

  public String getName() {
    return this.name
  }
  public int getAge() {
    return this.age
  }
  public void setName(String value) {
    this.name = value
  }
  public void setAge(int value) {
    this.age = value
  }
  public String toString() {
    return "Person(name=" + this.name + ", age=" + this.age + ")"
  }
  public boolean equals(Object o) {
    if (this == o) {
      return true
    }
    if (o == null || !(o instanceof Person)) {
      return false
    }
    Person other = (Person) o
    if (!other.canEqual(this)) {
      return false
    }
    /* the members are read in Lombok's order: the int, then the String */
    if (this.age != other.age) {
      return false
    }
    Object $this$name = this.name
    Object $other$name = other.name
    if ($this$name == null ? $other$name != null : !$this$name.equals($other$name)) {
      return false
    }
    return true
  }
  protected boolean canEqual(Object other) {
    return other instanceof Person
  }
  public int hashCode() {
    int result = 1
    result = 59 * result + this.age
    Object $hash$name = this.name
    result = 59 * result + ($hash$name == null ? 43 : $hash$name.hashCode())
    return result
  }
}

差異說明:

  • @Getter/@Setter 必須與 @Data、@Value 或類別層級註解搭配才會涵蓋所有欄位; 寫在單一欄位上只影響該欄位。
  • @Data 產生的建構子是 @RequiredArgsConstructor(final 且無初始值的欄位,加上 標了 @NonNull 的欄位,與 Lombok 相同)。若類別沒有這類欄位,就是無參數建構子; 要全參數建構子請同時加 @AllArgsConstructor。
  • @Builder 不會產生 getter,與 Lombok 相同。
  • @Getter(lazy = true) 把欄位的初始值搬進 getter:建構子不再算它,第一次讀取算一次 之後快取(與 Lombok 相同)。持有值是 boxed 的,所以原生型別也可以。產生的 getter 是 雙重檢查的(與 Lombok 一樣會同步),而且算出來是 null 也會被快取——八條執行緒 同時讀一個睡著的 initializer 只會算一次(t267_lombok_lazy_threads),回傳 null 的 initializer 也只算一次(t268_lombok_lazy_null);這兩條以前是差異,PR #173 之後不是。
  • @EqualsAndHashCode 的 hashCode 用 Lombok 自己的常數(59、43、布林的 79/97、long 與 double 的位移─互斥)與 Lombok 的欄位順序,而且產生 canEqual(t270_lombok_equals,它的 .java.ref 是 Lombok 自己展開的程式碼、期望值 是 JDK 跑出來的)。equals 與 hashCode 一起產生或一起不產生:寫了其中一個,另一個 就不會被產生(t271_lombok_equals_handwritten)。這些以前都是差異,PR #173 之後不是。
  • @ToString(callSuper = true) 產生的字串是 Child(super=Base(b=1), c=2), 與 Lombok 相同(t269_lombok_tostring_callsuper 驗了 super 那一段的四種形狀); 以前這裡寫的是 Teyru 自創的 Child(c=2; super=Base(b=1)),PR #173 收斂掉了。 之後快取(與 Lombok 相同)。持有值是 boxed 的,所以原生型別也可以。getter 是雙重 檢查的,與 Lombok 相同;Lombok 的持有欄位是一個 AtomicReference,把「還沒算」與 「算出來是 null」分開,並同步在那個 reference 上,這裡的持有欄位就是值本身(所以 null 不能拿來當「還沒算」用),monitor 因此是實例本身,而「算過了」是一個 volatile 的旗標——值先寫、旗標後寫,讀到旗標就讀得到值。
  • @StandardException 的 E(Throwable) 是 super(cause == null ? null : cause.getMessage(), cause),所以 new E(new RuntimeException("c")).getMessage() 是 c(與 Lombok 相同)。全參數 建構子走 super(message, cause);Lombok 是 super(message) 之後 initCause(cause),差別只在「先明確表示沒有 cause、之後還能 initCause」這個細節。

3. 與 Lombok 的差異(重要)

  1. 沒有 annotation processor。 展開發生在編譯器內部,javac 完全不參與。
  2. @NonNull 檢查的位置。 欄位標了 @NonNull、又被收進產生的建構子時會插檢查, @Setter 產生的 setter、手寫方法與建構子的參數(只標在參數上即可)也都會插檢查; 直接指派欄位不檢查——Lombok 的說明也只承諾「指派值給這個欄位的產生方法」會插 檢查,這點兩邊一致。差別在 @Builder:Lombok 在 builder 的 setter 上就檢查, builder().name(null) 當場丟;這裡的 builder setter 不檢查,檢查落在 build() 呼叫的建構子裡。
  3. @Singular 傳的是可變副本,不是 Collections.unmodifiableList 包裝(見 §4)。
  4. @SuperBuilder 產生一個攤平的 builder,不是 builder 繼承鏈(見 §4)。
  5. @onX 註解只會被複製,不會被執行。 註解字面上會掛到產生出來的成員上, 但 Teyru 沒有 java.lang.annotation 的執行期,所以 @Deprecated 之類的標記 不會有任何效果;需要反射讀取註解的框架在此不適用。
  6. 只讀 lombok.config 的一個鍵。 lombok.log.custom.declaration (@CustomLog 用)會被讀取;其餘鍵與 config.stopBubbling 都不讀,搜尋一律 走到檔案系統根目錄。
  7. @Value 的欄位一定是 private final;若欄位已經有初始值,建構子不會再收它。 final 是在檢查繼承之後才由標註標上去的,所以 class Ext extends V 由一個 補做的檢查擋下(TY-TYP-0007,訊息與 Lombok 的 cannot inherit from final V 同義)。@UtilityClass 的 final 走同一條路。
  8. @Builder 的 setterPrefix 會把名字的首字母大寫:setterPrefix = "with" 加欄位 name 產生 withName(Lombok 相同);沒有 prefix 時名字就是欄位名本身。 掛在方法上的 @Builder 會把目標方法的參數當成欄位,build() 呼叫該方法 (static 的用 類別.方法(...),實例方法用一個新實例);@Builder.ObtainVia 由 toBuilder 讀取,method/isStatic 兩種形式都支援。
  9. @Helper 只認方法內的區域類別。 那里會產生一個實例,宣告之後同名的未限定 呼叫都走它;寫在成員類別上沒有作用,只是把類別標成 static(Lombok 會直接報錯: @Helper is legal only on method-local classes)。 @Tolerate 則是讓產生器「看不到」被標的成員:@Setter private Instant date 加上 @Tolerate public void setDate(String) 之後兩個多載都在,與 Lombok 相同。

3.5 @onX 家族與 @CustomLog

@onMethod_/@onParam_/@onConstructor_

這些選項把一個註解複製到另一個註解產生出來的成員上:

class Annotated {
  @Getter(onMethod_ = @Deprecated) String name
  @Getter @Setter(onParam_ = @Deprecated) int age
}

@AllArgsConstructor(onConstructor_ = @Deprecated)
class Made {
  String a
  int b
}

onMethod_ 掛到 getter 上,onParam_ 掛到 setter 的參數上,onConstructor_ 掛到產生的建構子上。兩種寫法都讀:Lombok 的參數形式(含 javac7 時代的 @__(...) 包裝)與緊鄰在旁邊的 @onMethod_Deprecated 裸寫法。

只有單一個註解讀得回來:陣列形式 onMethod_ = {@A, @B} 會被靜默忽略,因為剖析器 不會保留陣列引數裡的註解。

註解只是被複製,不會被執行——Teyru 沒有 java.lang.annotation 的執行期, 所以標記本身沒有作用,是給後續的編譯器階段讀的。

@CustomLog

Lombok 只用 lombok.config 設定這一個註解。Teyru 讀 lombok.log.custom.declaration,格式與 Lombok 相同:

lombok.log.custom.declaration = MyLog MyLog.of(NAME)

第一個字是 logger 型別,後面是建立它的樣式;NAME 會被代換成掛註解的類別名稱, TYPE 在 Lombok 是類別物件。Teyru 不支援 TYPE:樣式裡的引數會傳給一個 靜態工廠,而 Teyru 的 X.class(見 docs/language.md)能表達的只有名稱, 硬傳會產生一個對不上工廠參數的東西,因此回報 TY-INT-0006 並要求改用 NAME。

搜尋規則與 Lombok 相同:從來源檔所在目錄往上找最近的 lombok.config,每個鍵 最近的一份為準。差別是 config.stopBubbling 不被讀取,搜尋一律走到根目錄。

若宣告的型別找不到,回報 TY-INT-0006;找不到 log 符號時則是一般的 TY-TYP-0048。


4. @Singular 與 @SuperBuilder

@Singular

@Builder
class Order {
  @Singular private List<String> items
  @Singular private Map<String, Integer> counts
  @Singular("tag") private List<String> tags
}

產生(以 items 為例):

成員行為
addItems(E value)第一次呼叫時建立 ArrayList,之後逐項加入
addItemsAll(List<E> values)整批加入
clearItems()清空(下次加入會重新建立)
build()傳入副本,且永遠不是 null

Map 欄位的加入方法用欄位名本身:counts(K key, V value)、countsAll(Map<K,V>)、 clearCounts()。@Singular("tag") 會把加入方法改名為 tag(E)。

命名與 Lombok 不同:Lombok 對 List 欄位 items 產生的是單數化的 item(E) 與 items(Collection),對 Map 欄位 counts 產生的是 count(K,V) 與 counts(Map)。 Teyru 一律是 add<欄位名>/add<欄位名>All,Map 的兩參數版本直接叫欄位名。

與 Lombok 的差別:Lombok 產生 java.util.Collections.unmodifiableList 包裝, Teyru 沒有那個 API,所以傳的是一份可變副本——物件與 builder 不共用同一個集合, 但拿到的人仍可修改它。

@SuperBuilder

@SuperBuilder
class Animal {
  private String name
  private int legs
}

@SuperBuilder
class Dog extends Animal {
  private String breed
}

Dog d = Dog.builder().name("rex").legs(4).breed("lab").build()

Lombok 用「builder 繼承 builder,並以自我指涉的型別參數 B extends Builder<B> 回傳 自身」來讓鏈式呼叫跨層遺傳。Teyru 改成單一攤平的 builder:子類別的 builder 涵蓋 整條繼承鏈的欄位,build() 一次傳給子類別建構子,建構子再把父類別那一份往上傳。 鍊式寫法完全一樣,而且不需要泛型。

代價:Animal.builder() 與 Dog.builder() 是兩個獨立的類別,Dog 的 builder 不是 Animal 的 builder 的子類別。把 builder 當引數在繼承鏈之間傳遞的程式碼 在 Lombok 可以編譯,在這裡不行——這種寫法很少見。

5. 日誌註解

Teyru 沒有 SLF4J、Log4j 這些外部套件,標準程式庫提供一個簡單的 Logger:

class Logger {
  public Logger(String name)
  public void trace(String msg)
  public void debug(String msg)
  public void info(String msg)
  public void warn(String msg)
  public void error(String msg)
}

@Slf4j 等註解產生的欄位是 private static final Logger log = new Logger("類別名"), 輸出格式為 LEVEL 類別名 - 訊息,寫到標準輸出。要接真正的日誌系統,請自行把 log 欄位換成對應的實作。


6. 展開順序

  1. 類別層級的結構性註解(@Value、@FieldDefaults、@UtilityClass、@Data)先調整修飾符。
  2. 成員層級註解(@Getter、@Setter、@NonNull、@With、@Delegate …)逐欄位處理。
  3. 類別層級的產生器(@ToString、@EqualsAndHashCode、建構子、@Builder)最後執行。
  4. 產生的成員在 vtable 配置之前加入,因此它們和手寫成員一樣參與覆寫與多型。

若同一個簽章已經存在(手寫或產生),建構子會被跳過,方法則是 TY-TYP-0011 重複定義的編譯錯誤;標了 @Tolerate 的成員例外——產生器把它當成 不存在,於是照樣產生自己的那一份(Lombok 的行為:兩個多載,或是真正的重複定義 錯誤)。@Setter/@Getter 產生的存取子另外會先看名字有沒有被佔用,佔用了就不 產生,這也是 Lombok 的規則。

本頁目錄