本文主要介紹協程的用法, 以及使用協程能帶來什么好處. 另外, 也會粗略提一下協程的大致原理.
本文的意義可能僅僅是讓你了解一下協程, 并愿意開始使用它.
如果想徹底理解協程, 請查看官方文檔, 官方文檔鏈接將在文章的結尾給出.
如果你以前在別的語言里學習過協程, 如Python的yield, 那請你先忘記它們, 畢竟還是有些區別, 等你弄懂了Kotlin的協程, 再去作對比, 否則, 可能會有一些先入為主的思路來阻礙你理解, 我就吃過這個虧.
初識協程:
首先我們來瞄一眼協程是長啥樣的, 以下引用(copy)了官網的一個例子:
fun main(args: Array<String>) {
launch(CommonPool) {
delay(1000L)
println("World!")
}
println("Hello,")
Thread.sleep(2000L)
}
/*
運行結果: ("Hello,"會立即被打印, 1000毫秒之后, "World!"會被打印)
Hello,
World!
*/
姑且不管里面具體的細節, 上面代碼大體的運行流程是這樣的:
A. 主流程:
- 調用系統的launch方法啟動了一個協程, 跟隨的大括號可以看做是協程體.
(其中的CommonPool暫且理解成線程池, 指定了協程在哪里運行) - 打印出"Hello,"
- 主線程sleep兩秒
(這里的sleep只是保持進程存活, 目的是為了等待協程執行完)
B. 協程流程:
- 協程延時1秒
- 打印出"World!"
解釋一下delay方法:
在協程里delay方法作用等同于線程里的sleep, 都是休息一段時間, 但不同的是delay不會阻塞當前線程, 而像是設置了一個鬧鐘, 在鬧鐘未響之前, 運行該協程的線程可以被安排做了別的事情, 當鬧鐘響起時, 協程就會恢復運行.
協程啟動后還可以取消
launch方法有一個返回值, 類型是Job, Job有一個cancel
方法, 調用cancel方法可以取消協程, 看一個數羊的例子:
fun main(args: Array<String>) {
val job = launch(CommonPool) {
var i = 1
while(true) {
println("$i little sheep")
++i
delay(500L) // 每半秒數一只, 一秒可以輸兩只
}
}
Thread.sleep(1000L) // 在主線程睡眠期間, 協程里已經數了兩只羊
job.cancel() // 協程才數了兩只羊, 就被取消了
Thread.sleep(1000L)
println("main process finished.")
}
運行結果是:
1 little sheep
2 little sheep
main process finished.
如果不調用cancel, 可以數到4只羊.
協程的核心是suspend方法, 下面先講解一下suspend方法, 之后再繼續別的話題.
理解suspend方法:
suspend方法是協程的核心, 理解suspend方法是使用和理解協程的關鍵.
(suspend lambda和suspend方法差不多, 只是沒有名字, 不再單獨介紹了)
suspend方法的語法很簡單, 只是比普通方法只是多了個suspend
關鍵字:
suspend fun foo(): ReturnType {
// ...
}
suspend方法只能在協程里面調用, 不能在協程外面調用.
suspend方法本質上, 與普通方法有較大的區別, suspend方法的本質是異步返回(注意: 不是異步回調). 后面我們會解釋這句話的含義.
現在, 我們先來看一個異步回調的例子:
fun main(...) {
requestDataAsync {
println("data is $it")
}
Thead.sleep(10000L) // 這個sleep只是為了保活進程
}
fun requestDataAsync(callback: (String)->Unit) {
Thread() {
// do something need lots of times.
// ...
callback(data)
}.start()
}
邏輯很簡單, 就是通過異步的方法拉一個數據, 然后使用這個數據, 按照以往的編程方式, 若要接受異步回來的數據, 唯有使用callback.
但是假如使用協程, 可以不使用callback, 而是直接把這個數據"return"回來, 調用者不使用callback接受數據, 而是像調用同步方法一樣接受返回值. 如果上述功能改用協程, 將會是:
fun main(...) {
launch(Unconfined) { // 請重點關注協程里是如何獲取異步數據的
val data = requestDataAsync() // 異步回來的數據, 像同步一樣return了
println("data is $it")
}
Thead.sleep(10000L) // 請不要關注這個sleep
}
suspend fun requestDataAsync() { // 請注意方法前多了一個suspend關鍵字
return async(CommonPool) { // 先不要管這個async方法, 后面解釋
// do something need lots of times.
// ...
data // return data, lambda里的return要省略
}.await()
}
這里, 我們首先將requestDataAsync轉成了一個suspend方法, 其原型的變化是:
- 在前加了個
suspend
關鍵字. - 去除了原來的callback參數.
這里先不去深究這個方法的新實現, 后面會專門解釋.
這里需要關注的點是: 在協程里面, 調用suspend方法, 異步的數據像同步一樣般return了.
這是怎么做到的呢?
當程序執行到requestDataAsync內部時, 通過async啟動了另外一個新的子協程去拉取數據, 啟動這個新的子協程后, 當前的父協程就掛起了, 此時requestDataAsync還沒有返回.
子協程一直在后臺跑, 過了一段時間, 子協程把數據拉回來之后, 會恢復它的父協程, 父協程繼續執行, requestDataAsync就把數據返回了.
為了加深理解, 我們來對比一下另一個例子: 不使用協程, 將異步方法也可以轉成同步的方法(在單元測試里, 我們經常這么做):
fun main(...) {
val data = async2Sync() // 數據是同步返回了, 但是線程也阻塞了
println("data is $it")
// Thead.sleep(10000L) // 這一句在這里毫無意義了, 注釋掉
}
private var data = ""
private fun async2Sync(): String {
val obj = Object() // 隨便創建一個對象當成鎖使用
requestDataAsync { data ->
this.data = data // 暫存data
synchronized(locker) {
obj.notifyAll() // 通知所有的等待者
}
}
obj.wait() // 阻塞等待
return this.data
}
fun requestDataAsync(callback: (String)->Unit) {
// ...普通的異步方法
}
注意對比上一個協程的例子, 這樣做表面上跟它是一樣的, 但是這里main方法會阻塞的等待async2Sync()方法完成. 同樣是等待, 協程就不會阻塞當前線程, 而是自己主動放棄執行權, 相當于遣散當前線程, 讓它去干別的事情去.
為了更好的理解這個"遣散"的含義, 我們再來看一個例子:
fun main(args: Array<String>) {
// 1. 程序開始
println("${Thread.currentThread().name}: 1");
// 2. 啟動一個協程, 并立即啟動
launch(Unconfined) { // Unconfined意思是在當前線程(主線程)運行協程
// 3. 本協程在主線程上直接開始執行了第一步
println("${Thread.currentThread().name}: 2");
/* 4. 本協程的第二步調用了一個suspend方法, 調用之后,
* 本協程就放棄執行權, 遣散運行我的線程(主線程)請干別的去.
*
* delay被調用的時候, 在內部創建了一個計時器, 并設了個callback.
* 1秒后計時器到期, 就會調用剛設置的callback.
* 在callback里面, 會調用系統的接口來恢復協程.
* 協程在計時器線程上恢復執行了. (不是主線程, 跟Unconfined有關)
*/
delay(1000L) // 過1秒后, 計時器線程會resume協程
// 7. 計時器線程恢復了協程,
println("${Thread.currentThread().name}: 4")
}
// 5. 剛那個的協程不要我(主線程)干活了, 所以我繼續之前的執行
println("${Thread.currentThread().name}: 3");
// 6. 我(主線程)睡2秒鐘
Thread.sleep(2000L)
// 8. 我(主線程)睡完后繼續執行
println("${Thread.currentThread().name}: 5");
}
運行結果:
main: 1
main: 2
main: 3
kotlinx.coroutines.ScheduledExecutor: 4
main: 5
上述代碼的注釋詳細的列出了程序運行流程, 看完之后, 應該就能明白 "遣散" 和 "放棄執行權" 的含義了.
Unconfined
的含義是不給協程指定運行的線程, 逮到誰就使用誰, 啟動它的線程直接執行它, 但被掛起后, 會由恢復它的線程繼續執行, 如果一個協程會被掛起多次, 那么每次被恢復后, 都可能被不同線程繼續執行.
現在再來回顧剛剛那句: suspend方法的本質就是異步返回.
含義就是將其拆成 "異步" + "返回":
- 首先, 數據不是同步回來的(同步指的是立即返回), 而是異步回來的.
- 其次, 接受數據不需要通過callback, 而是直接接收返回值.
調用suspend方法的詳細流程是:
在協程里, 如果調用了一個suspend方法, 協程就會掛起, 釋放自己的執行權, 但在協程掛起之前, suspend方法內部一般會啟動了另一個線程或協程, 我們暫且稱之為"分支執行流"吧, 它的目的是運算得到一個數據.
當suspend方法里的*分支執行流"完成后, 就會調用系統API重新恢復協程的執行, 同時會數據返回給協程(如果有的話).
__為什么不能再協程外面調用suspend方法? __
suspend方法只能在協程里面調用, 原因是只有在協程里, 才能遣散當前線程, 在協程外面, 不允許遣散, 反過來思考, 假如在協程外面也能遣散線程, 會怎么樣, 寫一個反例:
fun main(args: Array<String>) {
requestDataSuspend();
doSomethingNormal();
}
suspend fun requestDataSuspend() {
// ...
}
fun doSomethingNormal() {
// ...
}
requestDataSuspend是suspend方法, doSomethingNormal是正常方法, doSomethingNormal必須等到requestDataSuspend執行完才會開始, 后果main方法失去了并行的能力, 所有地方都失去了并行的能力, 這肯定不是我們要的, 所以需要約定只能在協程里才可以遣散線程, 放棄執行權, 于是suspend方法只能在協程里面調用.
概念解釋: Continuation 與 suspension point
----個人建議專有名詞別翻譯成中文, 否則很容易因為斷句錯誤而產生誤解
協程的執行其實是斷斷續續的: 執行一段, 掛起來, 再執行一段, 再掛起來, ...
每個掛起的地方是一個suspension point
, 每一小段執行是一個Continuation
.
協程的執行流被它的 "suspension point" 分割成了很多個 "Continuation" .
我們可以用一條畫了很多點的線段來表示:
其中的Continuation 0
比較特殊, 是從起點開始, 到第一個suspension point結束, 由于它的特殊性, 又被稱為Initial Continuation
.
協程創建后, 并不總是立即執行, 要分是怎么創建的協程, 通過launch方法的第二個參數是一個枚舉類型
CoroutineStart
, 如果不填, 默認值是DEFAULT
, 那么久協程創建后立即啟動, 如果傳入LAZY
, 創建后就不會立即啟動, 直到調用Job的start
方法才會啟動.
suspension point只是一個概念, 而Continuation在Kotlin里有一個對應interface, 關于這個interface后面再介紹.
封裝異步回調方法
在沒有協程的世界里, 通常異步的方法都需要接受一個callback用于發布運算結果.
在協程里, 所有接受callback的方法, 都可以轉成不需要callback的suspend方法.
上面的requestDataSuspend方法就是一個這樣的例子, 我們回過頭來再看一眼:
suspend fun requestDataSuspend() {
return async(CommonPool) {
// do something need lots of times.
// ...
data // return data
}.await()
}
其內部通過調用了async和await方法來實現(關于async和await我們后面再介紹), 這樣雖然實現功能沒問題, 但并不最合適的方式, 上面那樣做只是為了追求最簡短的實現, 合理的實現應該是調用suspendCoroutine
方法, 大概是這樣:
suspend fun requestDataSuspend() {
suspendCoroutine { cont ->
// ... 細節暫時省略
}
}
// 可簡寫成:
suspend fun requestDataSuspend() = suspendCoroutine { cont ->
// ...
}
在完整實現之前, 需要先理解suspendCoroutine
方法, 它是Kotlin標準庫里的一個方法, 原型如下:
suspend fun <T> suspendCoroutine(block: (Continuation<T>) -> Unit): T
后面我們討論Kotlin協程官方API的時候就會知道, 這是Kotlin標準庫里用于支持協程的底層API非常少(大多數API不在標準庫, 而是在應用層的擴展庫, 如上面的launch方法), 這是其中一個.
suspendCoroutine的作用就是將當前執行流掛起, 在適合的時機再將協程恢復執行, 我們可以看到他的參數是一個lambda, lambda的參數是一個Continuation
, 我們剛剛其實已經提到過Continuation了, 它表示一段執行流, 這里就不做過多解釋了, 這個方法里的Continuation實例
代表的執行流是從當前的suspension point開始, 到下一個suspension point結束, 當前的suspension point就是調用suspendCoroutine這一刻.
調用suspendCoroutine之后, 當前的執行流會掛起(調用suspendCoroutine的線程會遣散, 但不是整個進程都掛起, 不然誰做事呢), 然后開另一個執行流去做異步的事情, 等到異步的事情做完, 當前的執行流又會恢復, 下面看一下是如何恢復的.
suspendCoroutine的會自動捕獲當前的執行環境(如臨時變量, 參數等), 然后存放到一個Continuation中, 并且作為參數傳給它的lambda.
之前已經提到Continuation是標準庫里的一個interface, 它的原型是:
interface Continuation<in T> {
val context: CoroutineContext // 暫時不管這個
fun resume(value: T)
fun resumeWithException(exception: Throwable)
}
它有兩個方法resume
和resumeWithException
:
- 若調用resume就是正?;謴?/li>
- 調用resumeWithException就是異?;謴?/li>
現在來完善一下剛剛的例子:
suspend fun requestDataSuspend() = suspendCoroutine { cont ->
requestDataFromServer { data -> // 普通方法還是通過callback接受數據
if (data != null) {
cont.resume(data)
} else {
cont.resumeWithException(MyException())
}
}
}
/** 普通的異步回調方法 */
fun requestDataFromServer(callback: (String)->Unit) {
// ... get data from server, it will call back when finished.
}
邏輯很簡單, 如果data有效就正?;謴? 否則異?;謴?
但這里需要注意的是: 傳給resume的參數會變成suspendCoroutine的返回值, 進而成為了requestDataSuspend方法的返回值.
這個地方太神奇了, Kotlin是如何做到的呢?, 估計短時間也難以理解, 先記住吧.
suspendCoroutine有個特點:
suspendCoroutine { cont ->
// 如果本lambda里返回前, cont的resume和resumeWithException都沒有調用
// 那么當前執行流就會掛起, 并且掛起的時機是在suspendCoroutine之前
// 就是在suspendCoroutine內部return之前就掛起了
// 如果本lambda里返回前, 調用了cont的resume或resumeWithException
// 那么當前執行流不會掛起, suspendCoroutine直接返回了,
// 若調用的是resume, suspendCoroutine就會像普通方法一樣返回一個值
// 若調用的是resumeWithException, suspendCoroutine會拋出一個異常
// 外面可以通過try-catch來捕獲這個異常
}
回過頭來看一下, 剛剛的實現有調用resume方法嗎, 我們把它折疊一下:
suspend fun requestDataSuspend() = suspendCoroutine { cont ->
requestDataFromServer { ... }
}
清晰了吧, 沒有調用, 所以suspendCoroutine還沒有返回之前就掛起了, 但是掛起之前lambda執行完了, lambda里調用了requestDataFromServer, requestDataFromServer里啟動了真正做事的流程(異步執行的), 而suspendCoroutine則在掛起等待.
等到requestDataFromServer完成工作, 就會調用傳入的callback, 而這個callback里調用了cont.resume(data), 外層的協程就恢復了, 隨后suspendCoroutine就會返回, 返回值就是data.
大家一定很好奇Kotlin內部是如何實現的, 要像徹底了解其中的奧妙, 還是要看官方文檔和代碼, 這里只簡單介紹一下大致原理, 太細的我也不懂, 大家湊合著看一下, 看不懂也沒關系.
在Kotlin內部, 協程被實現成了一個狀態機, 狀態的個數就是suspension point的個數+1(初始狀態), 當前的狀態就是當前的suspension point, 當調用resume時, 就會執行下一個Continuation.
估計大家這個時候應該是似懂非懂, 其實作為使用者, 這已經夠了, 但是要深入研究, 還是靠自己研究代碼.
async/await模式:
我們前面多次使用了launch方法, 它的作用是創建協程并立即啟動, 但是有一個問題, 就是通過launch方法創建的協程都沒辦法攜帶返回值. async之前也出現過, 但一直沒有詳細介紹.
async方法作用和launch方法基本一樣, 創建一個協程并立即啟動, 但是async創建的協程可以攜帶返回值.
launch方法的返回值類型是Job, async方法的返回值類型是Deferred, 是Job的子類, Deferred里有個await
方法, 調用它可得到協程的返回值.
async/await
是一種常用的模式, async的含義是啟動一個異步操作, await的含義是等待這個異步操作結果.
是誰要等它啊, 在傳統的不使用協程的代碼里, 是線程在等(線程不干別的事, 就在那里傻等). 在協程里不是線程在等, 而且是執行流在等, 當前的流程掛起(底下的線程會被遣散去干別的事), 等到有了運算結果, 流程才繼續運行.
所以我們又可以順便得出一個結論: 在協程里執行流是線性的, 其中的步驟無論是同步的還是異步的, 后面的步驟都會等前面的步驟完成.
我們可以通過async起多個任務, 他們會同時運行, 我們之前使用的async姿勢不是很正常, 下面看一下使用async正常的姿勢:
fun main(...) {
launch(Unconfined) {
// 任務1會立即啟動, 并且會在別的線程上并行執行
val deferred1 = async { requestDataAsync1() }
// 上一個步驟只是啟動了任務1, 并不會掛起當前協程
// 所以任務2也會立即啟動, 也會在別的線程上并行執行
val deferred2 = async { requestDataAsync2() }
// 先等待任務1結束(等了約1000ms),
// 然后等待任務2, 由于它和任務1幾乎同時啟動的, 所以也很快完成了
println("data1=$deferred2.await(), data2=$deferred2.await()")
}
Thead.sleep(10000L) // 繼續無視這個sleep
}
suspend fun requestDataAsync1(): String {
delay(1000L)
return "data1"
}
suspend fun requestDataAsync2(): String {
delay(1000L)
return "data2"
}
運行結果很簡單, 不用說了, 但是協程總耗時是多少呢, 約1000ms, 不是2000ms, 因為兩個任務是并行運行的.
有一個問題: 假如任務2先于任務1完成, 結果是怎樣的呢?
答案是: 任務2的結果會先保存在deferred2里, 當調用deferred2.await()時, 會立即返回, 不會引起協程掛起, 因為deferred2已經準備好了.
所以, suspend方法并不總是引起協程掛起, 只有其內部的數據未準備好時才會.
需要注意的是: await是suspend方法, 但async不是, 所以它才可以在協程外面調用, async只是啟動了協程, async本身不會引起協程掛起, 傳給async的lambda(也就是協程體)才可能引起協程掛起.
async/await模式在別的語言里, 被實現成了兩個關鍵字, 但在Kotlin里只是兩個很平常的方法.
Generators介紹:
學習Python的協程的時候, 最先學習的就是Generators, 它的作用就是通過計算產生序列, 而不用通過列表之類存儲機制. 以下通過Generators產生斐波那契序列:
// inferred type is Sequence<Int>
val fibonacci = buildSequence {
yield(1) // first Fibonacci number
var cur = 1
var next = 1
while (true) {
yield(next) // next Fibonacci number
val tmp = cur + next
cur = next
next = tmp
}
}
fun main(...) {
launch(Unconfined) { // 請重點關注協程里是如何獲取異步數據的
fibonacci.take(10).forEach { print("$it, ") }
}
Thead.sleep(10000L) // 請不要關注這個sleep
}
// will print 1, 1, 2, 3, 5, 8, 13, 21, 34, 55,
我覺得這個沒什么好解釋的, yield
是一個suspend方法, 放棄執行權, 并將數據返回.
根據前面的知識, 我們可以推斷出, yield內部肯定最終會調用到Continuation的resume方法.
yield在別的語言, 一般是一個關鍵字, Kotlin中也是一個方法.
yield是標準庫里的API, 大多數情況我們不需要直接調用這個方法, 使用kotlinx.coroutines里面的Channel
和produce
方法更加方法. 具體可以參考這里:
https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.md#building-channel-producers
序列的產生跟RX其實有點像, 但也是區別的, 具體可以參考這里:
https://github.com/Kotlin/kotlinx.coroutines/blob/master/reactive/coroutines-guide-reactive.md
目前沒有發現特別需要使用Generators的場景, 所以這里不做太多討論.
協程API說明:
Kotlin的開發者對協程的實現比較獨特, 語言機制本身只增加了極少的關鍵字, 標準庫也只有極少的API, 但這并不代表功能少, 根據Kotlin的設計, 很多功能型API都放到了更上層的應用庫里去實現.
只將少量核心的機制才放到語言本身和標準庫上. 這樣做不僅使得語言更簡單, 而且靈活性更強.
Kotlin官方對協程提供的三種級別的能力支持, 分別是: 最底層的語言層, 中間層標準庫(kotlin-stdlib), 以及最上層應用層(kotlinx.coroutines).
應用層:
這一層是我們的程序直接調用的層, 提供一些常用的實現方法, 如launch方法, async方法等, 它的實現在kotlinx.coroutines里面.
標準庫:
標準庫僅僅提供了少量創建協程的方法, 位于:
kotlin.coroutines.experimental:
-- createCoroutine()
-- startCoroutine()
-- suspendCoroutine()
到目前為止, 我們直接使用到的只有suspendCoroutine
方法.
launch和async方法的實現里最終調用了startCoroutine
方法.
Generators里的buildSequence方法, 最終會調用createCoroutine
來實現.
語言層:
語言本身主要提供了對suspend
關鍵字的支持, Kotlin編譯器會對suspend修飾的方法或lambda特殊處理, 生成一些中間類和邏輯代碼.
我們平常用到的基本都是應用層的接口, 應用層提供了很多非常核心功能, 這些功能在其他語言里大多是通過關鍵字來實現的, 而在Kotlin里, 這些都是實現成了方法.
總結
協程是什么:
看了這么多例子, 我們現在可以總結一下協程是什么, 協程到底是什么, 很難給出具體的定義, 就算能給出具體定義, 也會非常抽象難以理解的.
另一方面, 協程可以說是編譯器的能力, 因為協程并不需要操作系統和硬件的支持(線程需要), 是編譯器為了讓開發者寫代碼更簡單方便, 提供了一些關鍵字, 并在內部自動生成了一些支持型代碼(可能是字節碼).
以下我個人的總結:
- 首先, 協程是一片包含特定邏輯的代碼塊, 這個代碼塊可以調度到不同的線程上執行;
- 其次, 協程一種環境, 在這種環境里, 方法可以被等待執行, 有了運算結果之后才返回, 在等待期間, 承載協程的線程資源可以被別的地方使用.
- 第三, 協程是一個獨立于運行流程的邏輯流程, 協程里面的步驟, 無論是同步的還是異步的, 都是線性(從前到后依次完成的).
協程和線程區別與關系:
線程和協程的目的本質上存在差異:
- 線程的目的是提高CPU資源使用率, 使多個任務得以并行的運行, 所以線程是為了服務于機器的.
- 協程的目的是為了讓多個任務之間更好的協作, 主要體現在代碼邏輯上, 所以協程是為了服務于人的, 寫代碼的人. (也有可能結果會能提升資源的利用率, 但并不是原始目的)
在調度上, 協程跟線程也不同:
- 線程的調度是系統完成的, 一般是搶占式的, 根據優先級來分配, 是空分復用.
- 協程的調度是開發者根據程序邏輯指定好的, 在不同的時期把資源合理的分配給不同的任務, 是時分復用的.
作用上的不同:
- 協程確保了代碼邏輯是順序的, 不管同步操作要是異步操作, 前一個完成, 后一個才會開始.
- 線程可以被調度到CPU上執行, 這樣代碼才能真正運行起來.
協程與線程的關系:
協程并不是取代線程, 而且抽象于線程之上, 線程是被分割的CPU資源, 協程是組織好的代碼流程, 協程需要線程來承載運行, 線程是協程的資源, 但協程不會直接使用線程, 協程直接利用的是執行器(Interceptor), 執行器可以關聯任意線程或線程池, 可以使當前線程, UI線程, 或新建新程. 可總結如下:
- 線程是協程的資源.
- 協程通過Interceptor來間接使用線程這個資源.
結語:
如果需要經常使用協程, 建議抽時間看一下官方文檔.
最后, 感謝大家的閱讀, 希望本文對你有所幫助 !
官方英文文檔連接:
_
第1個頁面是是官方指南的子頁面, 第2個和第3個分別是兩個GitHub項目里面的markdown文檔, 他們所在的工程還包含其他文檔, 有需要可以自行瀏覽.
另外, 提個建議: 如果看著看著卡殼了, 可以跳過或查閱另外幾個文檔, 以后再回過來看, 別的文檔有可能會用別的方式或別的例子來描述了同一個東西)
_