[译]Effective Kotlin系列之探索高阶函数中inline修饰符(三)
簡述: 不知道是否有小伙伴還記得我們之前的Effective Kotlin翻譯系列,之前一直忙于趕時髦研究Kotlin 1.3中的新特性。把此系列耽擱了,趕完時髦了還是得踏實探究本質和基礎,從今天開始我們將繼續探索Effective Kotlin系列,今天是Effective Kotlin第三講。
翻譯說明:
原標題: Effective Kotlin: Consider inline modifier for higher-order functions
原文地址: blog.kotlin-academy.com/effective-k…
原文作者: Marcin Moskala
你或許已經注意到了所有集合操作的函數都是內聯的(inline)。你是否問過自己它們為什么要這么定義呢? 例如,這是Kotlin標準庫中的filter函數的簡化版本的源碼:
inline fun <T> Iterable<T>.filter(predicate: (T)->Boolean): List<T>{val destination = ArrayList<T>()for (element in this) if (predicate(element))destination.add(element)return destination } 復制代碼這個inline修飾符到底有多重要呢? 假設我們有5000件商品,我們需要對已經購買的商品累計算出總價。我們可以通過以下方式完成:
products.filter{ it.bought }.sumByDouble { it.price } 復制代碼在我的機器上,運行上述代碼平均需要38毫秒。如果這個函數不是內聯的話會是多長時間呢? 不是內聯在我的機器上大概平均42毫秒。你們可以自己檢查嘗試下,這里是完整源碼. 這似乎看起來差距不是很大,但每調用一次這個函數對集合進行處理時,你都會注意到這個時間差距大約為10%左右。
當我們修改lambda表達式中的局部變量時,可以發現差距將會更大。對比下面兩個函數:
inline fun repeat(times: Int, action: (Int) -> Unit) {for (index in 0 until times) {action(index)} }fun noinlineRepeat(times: Int, action: (Int) -> Unit) {for (index in 0 until times) {action(index)} } 復制代碼你可能已經注意到除了函數名不一樣之外,唯一的區別就是第一個函數使用inline修飾符,而第二個函數沒有。用法也是完全一樣的:
var a = 0 repeat(100_000_000) {a += 1 } var b = 0 noinlineRepeat(100_000_000) {b += 1 } 復制代碼上述代碼在執行時間上對比有很大的差異。內聯的repeat函數平均運行時間是0.335ns, 而noinlineRepeat函數平均運行時間是153980484.884ns。大概是內聯repeat函數運行時間的466000倍! 你們可以自己檢查嘗試下,這里是完整源碼.
為什么這個如此重要呢? 這種性能的提升是否有其他的成本呢? 我們應該什么時候使用內聯(inline)修飾符呢?這些都是重點問題,我們將盡力回答這些問題。然而這一切都需要從最基本的問題開始: 內聯修飾符到底有什么作用?
內聯修飾符有什么作用?
我們都知道函數通常是如何被調用的。先執行跳轉到函數體,然后執行函數體內所有的語句,最后跳回到最初調用函數的位置。
盡管強行對函數使用inline修飾符標記,但是編譯器將會以不同的方式來對它進行處理。在代碼編譯期間,它用它的主體替換這樣的函數調用。 print函數是inline函數:
public inline fun print(message: Int) {System.out.print(message) } 復制代碼當我們在main函數中調用它時:
fun main(args: Array<String>) {print(2)print(2) } 復制代碼編譯后,它將變成下面這樣:
public static final void main( String[] args) {System.out.print(2)System.out.print(2) } 復制代碼這里有一點不一樣的是我們不需要跳回到另一個函數中。雖然這種影響可以忽略不計。這就是為什么你定義這樣的內聯函數時會在IDEA IntelliJ中發出以下警告:
為什么IntelliJ建議我們在含有lambda表達式作為形參的函數中使用內聯呢?因為當我們內聯函數體時,我們不需要從參數中創建lambda表達式實例,而是可以將它們內聯到函數調用中來。這個是上述repeat函數的調用:
repeat(100) { println("A") } 復制代碼將會編譯成這樣:
for (index in 0 until 1000) {println("A") } 復制代碼正如你所看見的那樣,lambda表達式的主體println("A")替換了內聯函數repeat中action(index)的調用。讓我們看另一外個例子。filter函數的用法:
val products2 = products.filter { it.bought } 復制代碼將被替換為:
val destination = ArrayList<T>() for (element in this) if (predicate(element))destination.add(element) val products2 = destination 復制代碼這是一項非常重要的改進。這是因為JVM天然地不支持lambda表達式。說清楚lambda表達式是如何被編譯的是件很復雜的事。但總的來說,有兩種結果:
- 匿名類
- 單獨的類
我們來看個例子。我們有以下lambda表達式:
val lambda: ()->Unit = {// body } 復制代碼它變成了JVM中的匿名類:
// Java Function0 lambda = new Function0() {public Object invoke() {// code} }; 復制代碼或者它變成了單獨的文件中定義的普通類:
// Java // Additional class in separate file public class TestInlineKt$lambda implements Function0 {public Object invoke() {// code} } // Usage Function0 lambda = new TestInlineKt$lambda() 復制代碼第二種效率更高,我們盡可能使用這種。僅僅當我們需要使用局部變量時,第一種才是必要的。
這就是為什么當我們修改局部變量時,repeat和noinlineRepeat之間存在如此之大的運行速度差異的原因。非內聯函數中的Lambda需要編譯為匿名類。這是一個巨大的性能開銷,從而導致它們的創建和使用都較慢。當我們使用內聯函數時,我們根本不需要創建任何其他類。自己檢查一下。編譯這段代碼并把它反編譯為Java代碼:
fun main(args: Array<String>) {var a = 0repeat(100_000_000) {a += 1}var b = 0noinlineRepeat(100_000_000) {b += 1} } 復制代碼你會發現一些相似的東西:
/ Java public static final void main(@NotNull String[] args) {int a = 0;int times$iv = 100000000;int var3 = 0;for(int var4 = times$iv; var3 < var4; ++var3) {++a;}final IntRef b = new IntRef();b.element = 0;noinlineRepeat(100000000, (Function1)(new Function1() {public Object invoke(Object var1) {++b.element;return Unit.INSTANCE;}})); } 復制代碼在filter函數例子中,使用內聯函數改進效果不是那么明顯,這是因為lambda表達式在非內聯函數中是編譯成普通的類而非匿名類。所以它的創建和使用效率還算比較高,但仍有性能開銷,所以也就證明了最開始那個filter例子為什么只有10%的運行速度差異。
集合流處理方式與經典處理方式
內聯修飾符是一個非常關鍵的元素,它能使集合流處理的方式與基于循環的經典處理方式一樣高效。它經過一次又一次的測試,在代碼可讀性和性能方面已經優化到極點了,并且相比之下經典處理方式總是有很大的成本。例如,下面的代碼:
return data.filter { filterLoad(it) }.map { mapLoad(it) } 復制代碼工作原理與下面代碼相同并具有相同的執行時間:
val list = ArrayList<String>() for (it in data) {if (filterLoad(it)) {val value = mapLoad(it)list.add(value)} } return list 復制代碼基準測量的具體結果(源碼在這里):
Benchmark (size) Mode Cnt Score Error Units filterAndMap 10 avgt 200 561.249 ± 1 ns/op filterAndMap 1000 avgt 200 29803.183 ± 127 ns/op filterAndMap 100000 avgt 200 3859008.234 ± 50022 ns/opfilterAndMapManual 10 avgt 200 526.825 ± 1 ns/op filterAndMapManual 1000 avgt 200 28420.161 ± 94 ns/op filterAndMapManual 100000 avgt 200 3831213.798 ± 34858 ns/op 復制代碼從程序的角度來看,這兩個函數幾乎相同。盡管從可讀性的角度來看第一種方式要好很多,這就是為什么我們應該總是寧愿使用智能的集合流處理函數而不是自己去實現整個處理過程。此外如果stalib庫中集合處理函數不能滿足我們的需求時,請不要猶豫,自己動手編寫集合處理函數。例如,當我需要轉置集合中的集合時,這是我在上一個項目中添加的函數:
fun <E> List<List<E>>.transpose(): List<List<E>> {if (isEmpty()) return thisval width = first().sizeif (any { it.size != width }) {throw IllegalArgumentException("All nested lists must have the same size, but sizes were ${map { it.size }}")}return (0 until width).map { col ->(0 until size).map { row -> this[row][col] }} } 復制代碼記得寫一些單元測試:
class TransposeTest {private val list = listOf(listOf(1, 2, 3), listOf(4, 5, 6))fun `Transposition of transposition is identity`() {Assert.assertEquals(list, list.transpose().transpose())}fun `Simple transposition test`() {val transposed = listOf(listOf(1, 4), listOf(2, 5), listOf(3, 6))assertEquals(transposed, list.transpose())} } 復制代碼內聯修飾符的成本
內聯不應該被過度使用,因為它也是有成本的。我想在代碼中打印出更多的數字2, 所以我就定義了下面這個函數:
inline fun twoPrintTwo() {print(2)print(2) } 復制代碼這對我來說可能還不夠,所以我添加了這個函數:
inline fun twoTwoPrintTwo() {twoPrintTwo()twoPrintTwo() } 復制代碼還是不滿意。我又定義了以下這兩個函數:
inline fun twoTwoTwoPrintTwo() {twoTwoPrintTwo()twoTwoPrintTwo() }fun twoTwoTwoTwoPrintTwo() {twoTwoTwoPrintTwo()twoTwoTwoPrintTwo() } 復制代碼然后我決定檢查編譯后的代碼中發生了什么,所以我將編譯為JVM字節碼然后將它反編譯成Java代碼。twoTwoPrintTwo函數已經很長了:
public static final void twoTwoPrintTwo() {byte var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1); } 復制代碼但是twoTwoTwoTwoPrintTwo就更加恐怖了
public static final void twoTwoTwoTwoPrintTwo() {byte var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1);var1 = 2;System.out.print(var1); } 復制代碼這說明了內聯函數的主要問題: 當我們過度使用它們時,會使得代碼體積不斷增大。這實際上就是為什么當我們使用他們時IntelliJ會給出警告提示。
內聯修飾符在不同方面的用法
內聯修飾符因為它特殊的語法特性而發生的變化遠遠超過我們在本篇文章中看到的內容。它可以實化泛型類型。但是它也有一些局限性。雖然這與Effective Kotlin系列無關并且屬于是另外一個話題。如果你想要我闡述更多有關它,請在Twitter或評論中表達你的想法。
一般來說,我們應該什么時候使用內聯修飾符呢?
我們使用內聯修飾符時最常見的場景就是把函數作為另一個函數的參數時(高階函數)。集合或字符串處理(如filter,map或者joinToString)或者一些獨立的函數(如repeat)就是很好的例子。
這就是為什么inline修飾符經常被庫開發人員用來做一些重要優化的原因了。他們應該知道它是如何工作的,哪里還需要被改進以及使用成本是什么。當我們使用函數類型作為參數來定義自己的工具類函數時,我們也需要在項目中使用inline修飾符。當我們沒有函數類型作為參數,沒有reified實化類型參數并且也不需要非本地返回時,那么我們很可能不應該使用inline修飾符了。這就是為什么我們在非上述情況下使用inline修飾符會在Android Studio或IDEA IntelliJ得到一個警告原因。
譯者有話說
這是Effective Kotlin系列第三篇文章,講得是inline內聯函數存在使用時潛在隱患,一旦使用不當或者過度使用就會造成性能上損失?;谶@一點原作者從發現問題到剖析整個inline內聯函數原理以及最后如何去選擇在哪種場景下使用內聯函數。我相信有了這篇文章,你對Kotlin中的內聯函數應該是了然于胸了吧。后面會繼續Effective Kotlin翻譯系列,歡迎繼續關注~~~
Kotlin系列文章,歡迎查看:
Effective Kotlin翻譯系列
- [譯]Effective Kotlin系列之遇到多個構造器參數要考慮使用構建器(二)
- [譯]Effective Kotlin系列之考慮使用靜態工廠方法替代構造器(一)
原創系列:
- Jetbrains開發者日見聞(三)之Kotlin1.3新特性(inline class篇)
- JetBrains開發者日見聞(二)之Kotlin1.3的新特性(Contract契約與協程篇)
- JetBrains開發者日見聞(一)之Kotlin/Native 嘗鮮篇
- 教你如何攻克Kotlin中泛型型變的難點(實踐篇)
- 教你如何攻克Kotlin中泛型型變的難點(下篇)
- 教你如何攻克Kotlin中泛型型變的難點(上篇)
- Kotlin的獨門秘籍Reified實化類型參數(下篇)
- 有關Kotlin屬性代理你需要知道的一切
- 淺談Kotlin中的Sequences源碼解析
- 淺談Kotlin中集合和函數式API完全解析-上篇
- 淺談Kotlin語法篇之lambda編譯成字節碼過程完全解析
- 淺談Kotlin語法篇之Lambda表達式完全解析
- 淺談Kotlin語法篇之擴展函數
- 淺談Kotlin語法篇之頂層函數、中綴調用、解構聲明
- 淺談Kotlin語法篇之如何讓函數更好地調用
- 淺談Kotlin語法篇之變量和常量
- 淺談Kotlin語法篇之基礎語法
翻譯系列:
- [譯]Kotlin中內聯類的自動裝箱和高性能探索(二)
- [譯]Kotlin中內聯類(inline class)完全解析(一)
- [譯]Kotlin的獨門秘籍Reified實化類型參數(上篇)
- [譯]Kotlin泛型中何時該用類型形參約束?
- [譯] 一個簡單方式教你記住Kotlin的形參和實參
- [譯]Kotlin中是應該定義函數還是定義屬性?
- [譯]如何在你的Kotlin代碼中移除所有的!!(非空斷言)
- [譯]掌握Kotlin中的標準庫函數: run、with、let、also和apply
- [譯]有關Kotlin類型別名(typealias)你需要知道的一切
- [譯]Kotlin中是應該使用序列(Sequences)還是集合(Lists)?
- [譯]Kotlin中的龜(List)兔(Sequence)賽跑
實戰系列:
- 用Kotlin擼一個圖片壓縮插件ImageSlimming-導學篇(一)
- 用Kotlin擼一個圖片壓縮插件-插件基礎篇(二)
- 用Kotlin擼一個圖片壓縮插件-實戰篇(三)
- 淺談Kotlin實戰篇之自定義View圖片圓角簡單應用
歡迎關注Kotlin開發者聯盟,這里有最新Kotlin技術文章,每周會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~
總結
以上是生活随笔為你收集整理的[译]Effective Kotlin系列之探索高阶函数中inline修饰符(三)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: JavaScript四舍五入的改进
- 下一篇: 【漏洞复现】ThinkPHP5 5.x