原文做者: Jake Whartonjava
原文標題:Which is better on Android: divide by 2 or shift by 1?android
原文地址:https://jakewharton.com/which-is-better-on-android-divide-by-two-or-shift-by-one/web
譯者:秉心說shell
我一直在嘗試將 AndroidX collection library 移植到 Kotlin multiplatform,來測試二進制兼容性,性能,易用性和不一樣的內存模型。類庫中的一些數據結構使用基於數組實現的二叉樹來存儲元素。在 Java 代碼中有許多地方使用 移位操做 來代替二次冪的乘除法。當移植到 Kotlin 時,這些代碼會被轉化爲略顯變扭的中綴操做符,這有點混淆了代碼意圖。api
關於移位運算和乘/除法誰的性能更好,我作過一些調研,大多數人都據說過 「移位運算性能更好」,但也對其真實性保持質疑。一些人認爲代碼運行到 CPU 以前,編譯器可能會作一些優化。數組
爲了知足個人好奇心,同時避免使用 Kotlin 的中綴移位操做符,我會來回答誰更好以及一些相關問題。Let's go !安全
譯者注:Jake Wharton 吐槽的 Kotlin 移位操做是這麼寫的:
i shr 1
、i shl 1
數據結構
在咱們的代碼被 CPU 執行以前,有如下幾個重要的編譯器:javac/kotlinc
、D八、R8
和 ART
。jvm
其中的每一步都有機會作優化,可是它們作了嗎?編輯器
class Example {
static int multiply(int value) {
return value * 2;
}
static int divide(int value) {
return value / 2;
}
static int shiftLeft(int value) {
return value << 1;
}
static int shiftRight(int value) {
return value >> 1;
}
}
複製代碼
在 JDK 14 下編譯上面的代碼,並經過 javap
展現字節碼。
$ javac Example.java
$ javap -c Example
Compiled from "Example.java"
class Example {
static int multiply(int);
Code:
0: iload_0
1: iconst_2
2: imul
3: ireturn static int divide(int); Code: 0: iload_0 1: iconst_2 2: idiv 3: ireturn static int shiftLeft(int); Code: 0: iload_0 1: iconst_1 2: ishl 3: ireturn static int shiftRight(int); Code: 0: iload_0 1: iconst_1 2: ishr 3: ireturn } 複製代碼
每一個方法都以 iload_0
指令開頭,表示加載第一個參數。乘法和除法都是用 iconst_2
指令來加載字面量 2 。而後分別執行 imul
和 idiv
指令來進行 int 類型的乘除法。移位操做也是先加載字面量 1,而後利用 ishl
和 ishr
指令進行移位運算。
這裏沒有進行任何優化,可是若是你對 java 有所瞭解的話,也不會感到意外。javac
並非一個會進行優化的編譯器,而是把大部分工做留給了 JVM 上的運行時編譯器或者 AOT 。
fun multiply(value: Int) = value * 2
fun divide(value: Int) = value / 2
fun shiftLeft(value: Int) = value shl 1
fun shiftRight(value: Int) = value shr 1
複製代碼
在 Kotlin 1.4-M1 版本下經過 kotlinc
將 Kotlin 編譯成 Java 字節碼,再使用 javap
查看。
$ kotlinc Example.kt
$ javap -c ExampleKt
Compiled from "Example.kt"
public final class ExampleKt {
public static final int multiply(int);
Code:
0: iload_0
1: iconst_2
2: imul
3: ireturn public static final int divide(int); Code: 0: iload_0 1: iconst_2 2: idiv 3: ireturn public static final int shiftLeft(int); Code: 0: iload_0 1: iconst_1 2: ishl 3: ireturn public static final int shiftRight(int); Code: 0: iload_0 1: iconst_1 2: ishr 3: ireturn } 複製代碼
輸出結果和 Java 徹底一致。
This is using the original JVM backend of Kotlin, but using the forthcoming IR-based backend (via -Xuse-ir) also produces the same output.
上面這句裱起來,由於我看不懂 ~
使用最新的 D8 編譯器將上面示例的 Kotlin 代碼轉換的字節碼生成 DEX 文件。
$ java -jar $R8_HOME/build/libs/d8.jar \ --release \ --output . \ ExampleKt.class $ dexdump -d classes.dex Opened 'classes.dex', DEX version '035' Class #0 - Class descriptor : 'LExampleKt;' Access flags : 0x0011 (PUBLIC FINAL) Superclass : 'Ljava/lang/Object;' Direct methods - #0 : (in LExampleKt;) name : 'divide' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000118: |[000118] ExampleKt.divide:(I)I 000128: db00 0102 |0000: div-int/lit8 v0, v1, #int 2 // #02 00012c: 0f00 |0002: return v0#1 : (in LExampleKt;) name : 'multiply' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼
000130: |[000130] ExampleKt.multiply:(I)I 000140: da00 0102 |0000: mul-int/lit8 v0, v1, #int 2 // #02 000144: 0f00 |0002: return v0
#2 : (in LExampleKt;) name : 'shiftLeft' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼
000148: |[000148] ExampleKt.shiftLeft:(I)I 000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 00015c: 0f00 |0002: return v0
複製代碼#1 : (in LExampleKt;) name : 'multiply' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼#3 : (in LExampleKt;) name : 'shiftRight' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼000160: |[000160] ExampleKt.shiftRight:(I)I 000170: e100 0101 |0000: shr-int/lit8 v0, v1, #int 1 // #01 000174: 0f00 |0002: return v0 複製代碼#3 : (in LExampleKt;) name : 'shiftRight' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼
(略微優化了輸出結果)
Dalvik 字節碼是基於寄存器的,Java 字節碼是基於棧的。最終,每一個方法實際上都僅僅使用了一個字節碼來操做相關聯的整型數運算。它們都使用了 v1 寄存器來保存第一個方法參數,另外還須要一個字面量 1 或者 2。
因此不會產生任何改變,D8 並非一個優化編譯器(儘管它能夠作 method-local optimization) 。
爲了運行 R8,咱們須要配置混淆規則防止咱們的代碼被移除。
-keep,allowoptimization class ExampleKt {
<methods>;
}
複製代碼
上面的規則經過 --pg-conf
參數傳遞。
$ java -jar $R8_HOME/build/libs/r8.jar \
--lib $ANDROID_HOME/platforms/android-29/android.jar \
--release \
--pg-conf rules.txt \
--output . \
ExampleKt.class
$ dexdump -d classes.dex
Opened 'classes.dex', DEX version '035'
Class #0 -
Class descriptor : 'LExampleKt;'
Access flags : 0x0011 (PUBLIC FINAL)
Superclass : 'Ljava/lang/Object;'
Direct methods -
#0 : (in LExampleKt;)
name : 'divide'
type : '(I)I'
access : 0x0019 (PUBLIC STATIC FINAL)
code -
000118: |[000118] ExampleKt.divide:(I)I
000128: db00 0102 |0000: div-int/lit8 v0, v1, #int 2 // #02
00012c: 0f00 |0002: return v0
#1 : (in LExampleKt;) name : 'multiply' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000130: |[000130] ExampleKt.multiply:(I)I 000140: da00 0102 |0000: mul-int/lit8 v0, v1, #int 2 // #02 000144: 0f00 |0002: return v0 #2 : (in LExampleKt;) name : 'shiftLeft' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000148: |[000148] ExampleKt.shiftLeft:(I)I 000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 00015c: 0f00 |0002: return v0 #3 : (in LExampleKt;) name : 'shiftRight' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000160: |[000160] ExampleKt.shiftRight:(I)I 000170: e100 0101 |0000: shr-int/lit8 v0, v1, #int 1 // #01 000174: 0f00 |0002: return v0 複製代碼
和 D8 的輸出如出一轍。
使用上面 R8 輸出的 Dalvik 字節碼做爲 ART 的輸入,在 Android 10 的 x86 虛擬機上運行。
$ adb push classes.dex /sdcard/classes.dex
$ adb shell
generic_x86:/ $ su
generic_x86:/ # dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
generic_x86:/ # oatdump --oat-file=/sdcard/classes.oat
OatDexFile:
0: LExampleKt; (offset=0x000003c0) (type_idx=1) (Initialized) (OatClassAllCompiled)
0: int ExampleKt.divide(int) (dex_method_idx=0)
CODE: (code_offset=0x00001010 size_offset=0x0000100c size=15)...
0x00001010: 89C8 mov eax, ecx
0x00001012: 8D5001 lea edx, [eax + 1]
0x00001015: 85C0 test eax, eax
0x00001017: 0F4DD0 cmovnl/ge edx, eax
0x0000101a: D1FA sar edx
0x0000101c: 89D0 mov eax, edx
0x0000101e: C3 ret
1: int ExampleKt.multiply(int) (dex_method_idx=1)
CODE: (code_offset=0x00001030 size_offset=0x0000102c size=5)...
0x00001030: D1E1 shl ecx
0x00001032: 89C8 mov eax, ecx
0x00001034: C3 ret
2: int ExampleKt.shiftLeft(int) (dex_method_idx=2)
CODE: (code_offset=0x00001030 size_offset=0x0000102c size=5)...
0x00001030: D1E1 shl ecx
0x00001032: 89C8 mov eax, ecx
0x00001034: C3 ret
3: int ExampleKt.shiftRight(int) (dex_method_idx=3)
CODE: (code_offset=0x00001040 size_offset=0x0000103c size=5)...
0x00001040: D1F9 sar ecx
0x00001042: 89C8 mov eax, ecx
0x00001044: C3 ret
複製代碼
(略微優化了輸出結果)
x86 的彙編代碼代表 ART 介入了數學運算,並使用移位操做代替了其中的一部分。
首先,multiply
和 shiftLeft
如今具備一樣的實現,它們都使用 shl
來進行左移一位的操做。除此以外,若是你查看文件偏移量(最左邊一列)的話,你會發現是徹底同樣的。ART 識別到了這兩個方法具備同樣的方法體,並在編譯成 x86 彙編代碼時進行了去重操做。
而後,divide
和 shiftRight
的實現是不同的,它們沒有共同使用 sar
來進行右移一位的操做。divide
方法在調用 sar
以前額外使用了四條指令,來處理輸入是負數的狀況。
在 Android 10 Pixel4 的設備上執行相同的指令,來看看 ART 是如何將代碼編譯成 ARM 彙編代碼的。
OatDexFile:
0: LExampleKt; (offset=0x000005a4) (type_idx=1) (Verified) (OatClassAllCompiled)
0: int ExampleKt.divide(int) (dex_mmultiply and shiftLeft ethod_idx=0)
CODE: (code_offset=0x00001009 size_offset=0x00001004 size=10)...
0x00001008: 0fc8 lsrs r0, r1, #31
0x0000100a: 1841 adds r1, r0, r1
0x0000100c: 1049 asrs r1, #1
0x0000100e: 4608 mov r0, r1
0x00001010: 4770 bx lr
1: int ExampleKt.multiply(int) (dex_method_idx=1)
CODE: (code_offset=0x00001021 size_offset=0x0000101c size=4)...
0x00001020: 0048 lsls r0, r1, #1
0x00001022: 4770 bx lr
2: int ExampleKt.shiftLeft(int) (dex_method_idx=2)
CODE: (code_offset=0x00001021 size_offset=0x0000101c size=4)...
0x00001020: 0048 lsls r0, r1, #1
0x00001022: 4770 bx lr
3: int ExampleKt.shiftRight(int) (dex_method_idx=3)
CODE: (code_offset=0x00001031 size_offset=0x0000102c size=4)...
0x00001030: 1048 asrs r0, r1, #1
0x00001032: 4770 bx lr
複製代碼
一樣的,multiply
和 shiftLeft
使用 lsls
來完成左移一位的操做並去除了重複方法體。shiftRight
經過 asrs
指令完成右移,而除法使用了另外一個右移指令 lsrs
來處理輸入是負數的狀況。
到此爲止,咱們能夠確定的說,使用 value << 1
來代替 value * 2
不會帶來任何好處。 中止在算數運算中作這樣的事情吧,僅在嚴格要求按位運算的狀況下保留。
可是,value / 2
和 value >> 1
仍然會產生不一樣的彙編指令,所以也會有不同的性能表現。值得慶幸的是,value / 2
並不會進行通用的除法運算,仍然是基於移位操做,所以它們的性能差別可能並不大。
爲了肯定移位操做和除法運算誰更快,我使用了 Jetpack benchmark 進行了測試。
class DivideOrShiftTest {
@JvmField @Rule val benchmark = BenchmarkRule()
@Test fun divide() { val value = "4".toInt() // Ensure not a constant. var result = 0 benchmark.measureRepeated { result = value / 2 } println(result) // Ensure D8 keeps computation. } @Test fun shift() { val value = "4".toInt() // Ensure not a constant. var result = 0 benchmark.measureRepeated { result = value shr 1 } println(result) // Ensure D8 keeps computation. } } 複製代碼
我沒有 x86 設備,因此我在 Android 10 Pixel3 上進行了測試,結果以下:
android.studio.display.benchmark=4 ns DivideOrShiftTest.divide count=4006 mean=4 median=4 min=4 standardDeviation=0 複製代碼android.studio.display.benchmark=3 ns DivideOrShiftTest.shift count=3943 mean=3 median=3 min=3 standardDeviation=0 複製代碼
使用除法和移位實際上並無什麼區別,它們的差距是納秒級的。使用負數的話,結果不會有任何差別。
到此爲止,咱們能夠確定的說,使用 value >> 1
來代替 value / 2
不會帶來任何好處。 中止在算數運算中作這樣的事情吧,僅在嚴格要求按位運算的狀況下保留。
對於同一操做有兩種表達方式的話,應該選擇性能更優的。若是性能相同,就應該選擇能下降 Apk 體積的。
如今咱們都知道了 value * 2
和 value << 1
在 ART 上產生了相同的彙編代碼。所以,若是哪種可以在 Dalvik 上更加節省空間,咱們就應該毫無疑問的使用它來代替另外一種寫法。讓咱們來看看 D8 的輸出,它也產生了相同大小的字節碼:
#1 : (in LExampleKt;) name : 'multiply' ⋮ 000140: da00 0102 |0000: mul-int/lit8 v0, v1, #int 2 // #02複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 複製代碼000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 複製代碼
乘法有可能會耗費更多的空間用來存儲字面量。比較一下 value * 32_768
和 value << 15
。
#1 : (in LExampleKt;) name : 'multiply' ⋮ 000128: 1400 0080 0000 |0000: const v0, #float 0.000000 // #00008000 00012e: 9201 0100 |0003: mul-int v1, v1, v0複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 複製代碼00015c: e000 000f |0000: shl-int/lit8 v0, v0, #int 15 // #0f 複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 複製代碼
我在 D8 上提過這個 issue,但我強烈懷疑出現這一狀況的機率爲 0,因此這並不值得。
D8 和 R8 的輸出也代表,對於 Dalvik 來講,value / 2
和 value >> 1
的代價是相同的。
#0 : (in LExampleKt;) name : 'divide' ⋮ 000128: db00 0102 |0000: div-int/lit8 v0, v1, #int 2 // #02複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 複製代碼000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 複製代碼#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 複製代碼
當字面量大小達到 32768
時,上面的字節碼大小也會發生變化。因爲負數的緣由,無條件的使用右移來代替 2 次冪的除法並非絕對安全的。咱們能夠在保證非負數的狀況下進行替換。
Java 字節碼並無無符號數,但你可使用有符號數來模擬。Java 提供了靜態方法能夠將有符號數轉化爲無符號數。Kotlin 提供了無符號類型 UInt
,它提供了同樣的功能,但和 Java 不同的是,它獨立抽象爲一個數據類型。能夠想象到的是,二次冪的除法確定能夠用右移操做重寫。
使用 Kotlin 來演示下面兩種狀況。
fun javaLike(value: Int) = Integer.divideUnsigned(value, 2)
fun kotlinLike(value: UInt) = value / 2U
複製代碼
經過 kotlinc
編譯(Kotlin 1.4-M1) :
$ kotlinc Example.kt $ javap -c ExampleKt Compiled from "Example.kt" public final class ExampleKt { public static final int javaLike(int); Code: 0: iload_0 1: iconst_2 2: invokestatic #12 // Method java/lang/Integer.divideUnsigned:(II)I 5: ireturn 複製代碼public static final int kotlinLike-WZ4Q5Ns(int); Code: 0: iload_0 1: istore_1 2: iconst_2 3: istore_2 4: iconst_0 5: istore_3 6: iload_1 7: iload_2 8: invokestatic #20 // Method kotlin/UnsignedKt."uintDivide-J1ME1BU":(II)I 11: ireturn } 複製代碼
Kotlin 沒有識別到這是一個二次冪的除法,它原本能夠用 iushr
移位操做來代替。我向 Jetbrain 也提交過這個 issue 。
使用 -Xuse-i
也不會帶來任何改變(除了移除了一些 load/store)。可是,面向 Java8 就不同了。
$ kotlinc -jvm-target 1.8 Example.kt $ javap -c ExampleKt Compiled from "Example.kt" public final class ExampleKt { public static final int javaLike(int); Code: 0: iload_0 1: iconst_2 2: invokestatic #12 // Method java/lang/Integer.divideUnsigned:(II)I 5: ireturn 複製代碼public static final int kotlinLike-WZ4Q5Ns(int); Code: 0: iload_0 1: iconst_2 2: invokestatic #12 // Method java/lang/Integer.divideUnsigned:(II)I 5: ireturn } 複製代碼
Integer.divideUnsigned
方法從 Java 8 開始可用。因爲這樣讓兩個函數體徹底相同了,仍是回到舊版原本進行對比。
接下來是 R8。與上面明顯不一樣的是,咱們使用 Kotlin 標準庫做爲輸入,還指定了最低 api ,--min-api 24
。由於 Integer.divideUnsigned
僅在 API 24 及之後可用。
$ java -jar $R8_HOME/build/libs/r8.jar \ --lib $ANDROID_HOME/platforms/android-29/android.jar \ --min-api 24 \ --release \ --pg-conf rules.txt \ --output . \ ExampleKt.class kotlin-stdlib.jar $ dexdump -d classes.dex Opened 'classes.dex', DEX version '039' Class #0 - Class descriptor : 'LExampleKt;' Access flags : 0x0011 (PUBLIC FINAL) Superclass : 'Ljava/lang/Object;' Direct methods - #0 : (in LExampleKt;) name : 'javaLike' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 0000f8: |[0000f8] ExampleKt.javaLike:(I)I 000108: 1220 |0000: const/4 v0, #int 2 // #2 00010a: 7120 0200 0100 |0001: invoke-static {v1, v0}, Ljava/lang/Integer;.divideUnsigned:(II)I // method@0002 000110: 0a01 |0004: move-result v1 000112: 0f01 |0005: return v1複製代碼#1 : (in LExampleKt;) name : 'kotlinLike-WZ4Q5Ns' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼000114: |[000114] ExampleKt.kotlinLike-WZ4Q5Ns:(I)I 000124: 8160 |0000: int-to-long v0, v6 000126: 1802 ffff ffff 0000 0000 |0001: const-wide v2, #double 0.000000 // #00000000ffffffff 000130: c020 |0006: and-long/2addr v0, v2 000132: 1226 |0007: const/4 v6, #int 2 // #2 000134: 8164 |0008: int-to-long v4, v6 000136: c042 |0009: and-long/2addr v2, v4 000138: be20 |000a: div-long/2addr v0, v2 00013a: 8406 |000b: long-to-int v6, v0 00013c: 0f06 |000c: return v6 複製代碼#1 : (in LExampleKt;) name : 'kotlinLike-WZ4Q5Ns' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 複製代碼
Kotlin 有本身的無符號整數的實現,並直接內聯到了函數體內。它是這樣實現的,將參數和字面量轉化爲 long ,進行 long 的除法,最後轉換爲 int 。When we eventually run them through ART they’re just translated to equivalent x86 so we’re going to leave this function behind. (這句沒太懂)
。這裏已經錯失了優化機會。
對於 Java 版本,R8 也沒有使用移位運算來代替 divideUnsigned
。我已經提交 issue 來持續進行追蹤。
最後的優化機會就是 ART 。
$ adb push classes.dex /sdcard/classes.dex
$ adb shell
generic_x86:/ $ sugenzong
generic_x86:/ # dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
generic_x86:/ # oatdump --oat-file=/sdcard/classes.oat
OatDexFile:
0: LExampleKt; (offset=0x000003c0) (type_idx=1) (Initialized) (OatClassAllCompiled)
0: int ExampleKt.javaLike(int) (dex_method_idx=0)
CODE: (code_offset=0x00001010 size_offset=0x0000100c size=63)...
0x00001010: 85842400E0FFFF test eax, [esp + -8192]
StackMap[0] (native_pc=0x1017, dex_pc=0x0, register_mask=0x0, stack_mask=0b)
0x00001017: 55 push ebp
0x00001018: 83EC18 sub esp, 24
0x0000101b: 890424 mov [esp], eax
0x0000101e: 6466833D0000000000 cmpw fs:[0x0], 0 ; state_and_flags
0x00001027: 0F8519000000 jnz/ne +25 (0x00001046)
0x0000102d: E800000000 call +0 (0x00001032)
0x00001032: 5D pop ebp
0x00001033: BA02000000 mov edx, 2
0x00001038: 8B85CE0F0000 mov eax, [ebp + 4046]
0x0000103e: FF5018 call [eax + 24]
StackMap[1] (native_pc=0x1041, dex_pc=0x1, register_mask=0x0, stack_mask=0b)
0x00001041: 83C418 add esp, 24
0x00001044: 5D pop ebp
0x00001045: C3 ret
0x00001046: 64FF15E0020000 call fs:[0x2e0] ; pTestSuspend
StackMap[2] (native_pc=0x104d, dex_pc=0x0, register_mask=0x0, stack_mask=0b)
0x0000104d: EBDE jmp -34 (0x0000102d)
1: int ExampleKt.kotlinLike-WZ4Q5Ns(int) (dex_method_idx=1)
CODE: (code_offset=0x00001060 size_offset=0x0000105c size=67)...
⋮
複製代碼
ART 並無內聯調用 divideUnsigned
,取而代之的是常規的方法調用。我提交了這個 issue 進行跟蹤。
真是一段漫長的旅程,恭喜你已經完成了(或者只是翻到了文章底部)。讓咱們總結一下。
經過這些事實,你能夠回答文章開頭的問題了。
在 Android 上,選擇 除以2 仍是 右移1 ?
都不是!僅在實際須要按位操做時使用移位運算,其餘數學運算使用乘除法。我將着手將 AndroidX collection 的位運算切換到乘除法。下次見!
最近可能譯文會比較多,遇到一些好的文章老是忍不住要分享給你們。
其實譯文並不比原創文輕鬆,我至少都是在通讀一遍,精讀兩遍的基礎下,纔會下筆寫譯文。若是以爲文章不錯,盡情的在看,轉發,分享吧!
本文使用 mdnice 排版