Зачем мне твои неизменяемые коллекции? Они же медленные
Читая эту статью почти три года спустя, понимаю, что исследование весьма поверхностное, поэтому стоит воспринимать ее скептически.
Бывает так, что когда человек начинает писать на Kotlin, Scala или %language_name%
, он делает манипуляции с коллекциями так, как привык, как “удобно”, как “понятнее” или даже “как быстрее”. При этом, разумеется, используются циклы и изменяемые коллекции вместо функций высшего порядка (таких как filter
, map
, fold
и др.) и неизменяемых коллекций.
Эта статья - попытка убедить ортодоксов, что использование “этой вашей функциональщины” не влияет существенно на производительность. В качестве примеров использованы куски кода на Java, Scala и Kotlin, а для измерения скорости был выбран фреймворк для микробенчмаркинга JMH.
![](/assets/images/cat_immutable_collections.jpg)
Для тех, кто не в курсе, JMH (Java Microbenchmark Harness) - это сравнительно молодой фреймворк, в котором разработчики постарались учесть все нюасы JVM. Подробнее о нем можно узнать из докладов Алексея Шипилева (например, из этого). На мой взгляд, проще всего с ним познакомится на примерах, где вся необходимая информация есть в javadoc.
Самая интересная часть в написании бенчмарков заключалась в том, чтобы заставить JVM реально что-то делать. Эта умная зараза заранее знала ответ на все и выдавала ответы за мизерное время. Именно поэтому в их исходном коде фигурирует prepare
, чтобы обхитрить JVM. Кстати, Java Streams с предвычисленным результатом работали на порядок медленнее других способов.
Dicslaimer: все прекрасно знают, что написать бенчмарк правильно - невозможно, но предложения по тому, как написать его менее неправильно приветствуются. Если вам кажется, что обязательно надо рассмотреть пример с X, не стесняйтесь писать в комментариях, что еще надо потестить.
Кроме того, несмотря на то, что на одном графике будут и Kotlin, и Scala, и Java, считать это сравнением скорости кода на этих языках не стоит. Как минимум, в примерах на Scala есть накладные расходы на конвертацию scala.Int
⇔java.lang.Integer
и используются не Java-коллекции, а свои.
Исходный код примеров можно посмотреть на гитхабе, а все результаты в CSV - здесь. В качестве размера коллекций использовались значения от 10 до 100 000. Тестировать для бОльших размеров я особо не вижу смысла - для этого есть СУБД и другие способы работы с большими объемами данных. Все графики выполнены в логарифмической шкале и показывают среднее время выполнения операции в наносекундах.
Простой map
Начнем с самых простых примеров, которые есть в почти каждой статье про элементы функционального программирования:map
, filter
, fold
и flatMap
.
![](/assets/images/habr_immutable_collections_graph_map.png)
stream
, которые здесь себя не оправдывают. В Kotlin преобразование с использованием map
будет быстрее, чем цикл, причем даже быстрее, чем цикл на Java. Почему же это происходит? Смотрим исходный код map
:
@PublishedApi
internal fun <T> Iterable<T>.collectionSizeOrDefault(default: Int): Int =
if (this is Collection<*>) this.size else default
...
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(
destination: C,
transform: (T) -> R
): C {
for (item in this)
destination.add(transform(item))
return destination
}
...
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
Получаем выигрыш за счет того, что заранее известен конечный размер коллекции. Конечно можно и в Java так сделать, но часто ли вы такое встречали?
В Scala разница между циклом и map
уже ощутима. Если в Kotlin map
довольно прямолинейно конвертируется в цикл, то в Scala уже не все так просто: если неявный CanBuildFrom
- переиспользуемый ReusableCBFInstance
из списка, то применяется хитрый while
(заинтересованные могут посмотреть исходный код скаловского map
для списка здесь).
Простой filter
![](/assets/images/habr_immutable_collections_graph_filter.png)
В Kotlin разницы практически нет (прозрачная компиляция в цикл), но при больших объемах цикл чуть медленнее.
В Scala ситуация аналогична Java: на маленьких коллекциях функциональный подход немного проигрывает, на больших - нет разницы.
Простой fold
Не совсем подходящий пример (потому что на выходе число), но все равно довольно интересный.
![](/assets/images/habr_immutable_collections_graph_fold.png)
fold
в Scala работает медленнее, чем хотелось бы.
Простой flatMap
![](/assets/images/habr_immutable_collections_graph_flatmap.png)
Цепочка преобразований
Давайте рассмотрим еще пару примеров, в которых выполняются сложные преобразования. Отмечу, что очень много задач можно решить комбинациейfilter
и map
и очень длинные цепочки встречаются редко.
При этом если у вас все-таки получилась длинная цепочка преобразований, то имеет смысл перейти на ленивые вычисления (т.е. применять преобразования только тогда, когда необходимо). В Kotlin это преобразование к Sequence
, в Scala - iterator
. Streams в Java всегда выполняются лениво.
Рассмотрим цепочку flatMap
⇒filter
⇒fold
:
someList
.flatMap(this::generate)
.filter(this::filter)
.fold(initialValue(), this::doFold)
![](/assets/images/habr_immutable_collections_graph_chain.png)
iterator
разница сводится к нулю.
Цепочка со вложенными преобразованиями
Рассмотрим последовательность преобразований, где элементами промежуточной коллекции тоже являются коллекции, и над которыми тоже надо выполнять действия. Например топ-10 чего-то по какой-нибудь хитрой метрике:someList
.groupBy(this::grouping)
.mapValues {
it.value
.filter(this::filter)
.map(this::transform)
.sum()
}
.entries
.sortedByDescending { it.value }
.take(10)
.map { it.key }
Честно говоря, мне такое уже тяжело было написать в императивном стиле (к хорошему быстро привыкаешь; я сделал две тупых ошибки и в одном месте перепутал переменную).
Здесь результаты уже немного интереснее. На коллекциях размером до сотни разница между итеративным и функциональным подходом стала заметна. Однако на коллекциях большего размера разницей можно уже пренебречь. Стоит ли вам экономить 10 микросекунд процессорного времени? Особенно если при этом надо поддерживать код вроде такого:
Map<Integer, Integer> sums = new HashMap<>();
for (Integer element : someList) {
Integer key = grouping(element);
if (filter(element)) {
Integer current = sums.get(key);
if (current == null) {
sums.put(key, transform(element));
} else {
sums.put(key, current + transform(element));
}
}
}
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(sums.entrySet());
entries.sort(Comparator.comparingInt((x) -> -x.getValue()));
ArrayList<Integer> result = new ArrayList<>();
for (int i = 0; i < 10; i++) {
if (entries.size() <= i){
break;
}
result.add(entries.get(i).getKey());
}
return result;
Заключение
В целом получилось, что для всех случаев у функционального подхода если и есть проигрыш, то, на мой взгляд, он несущественный. При этом код, на мой взгляд, становится чище и читабельнее (если говорить про Kotlin, Scala и другие языки, изначально поддерживающие ФП), а под капотом могут быть полезные оптимизации.Не экономьте на спичках: вы же не пишете на ассемблере, потому что “так быстрее”? Действительно, может получиться быстрее, а может и нет. Компиляторы сейчас умнее одного программиста. Доверьте оптимизации им, это их работа.
Если вы все еще используете какой-нибудь mutableListOf
, задумайтесь. Во-первых, это больше строк кода и бОльшая вероятность сделать ошибку. Во-вторых, вы можете потерять оптимизации, которые появятся в будущих версиях языка (или уже есть там). В-третьих, при функциональном подходе лучше инкапсуляция: разделить filter
и map
проще, чем разбить цикл на два. В-четвертых, если вы уж пишете на языке, в котором есть элементы ФП и пишете “циклами”, то стоит следовать рекомендациям и стилю (например, Intellij Idea вам будет настоятельно рекомендовать заменить for
на соответствующее преобразование).
Как себе выстрелить в ногу в Kotlin
Совсем недавно вышел релиз Kotlin, а его команда разработчиков предлагала задавать вопросы про язык. Он сейчас на слуху и, возможно, многим хочется его попробовать. Пару недель назад тимлид сделал для компании презентацию о том, что в Котлине хорошо. Одним из самых интересных вопросов был “А как в Котлине выстрелить себе в ногу?” Так получилось, что ответил на этот вопрос я.
Disclaimer: Не стоит воспринимать эту статью как “Kotlin - отстой”. Хотя я отношусь скорее к категории тех, кому и со Scala хорошо, я считаю, что язык неплохой. Все пункты спорные, но раз в год и палка стреляет. Когда-то вы себе прострелите заодно и башку, а когда-то у вас получится выстрелить только в полночь полнолуния, если вы предварительно совершите черный ритуал создания плохого кода.
Наша команда недавно закончила большой проект на Scala, сейчас делаем проект помельче на Kotlin, поэтому в спойлерах будет сравнение со Scala. Я буду считать, что Nullable в Kotlin - это эквивалент Option, хотя это совсем не так, но, скорее всего, большинство из тех, кто работал с Option, будут вместо него использовать Nullable.
1. Пост-инкремент и преинкремент как выражения
Цитирую вопрошавшего: “Фу, это ж баян, скучно”. Столько копий сломано, миллион вопросов на собеседованиях C++… Если есть привычка, то можно было его оставить инструкцией (statement’ом). Справедливости ради, другие операторы, вроде +=, являются инструкциями. Цитирую одного из разработчиков, @abreslav:
Смотрели на юзкейсы, увидели, что поломается, решили оставить.На нет и суда нет. Разумеется, в здравом уме никто так делать не будет, но случайно - может быть.Замечу, что у нас тут не С++, и на собеседовании про инкремент спросить особо нечего. Разве что разницу между префиксным и постфиксным.
var i = 5
i = i++ + i++
println(i)
Никакого undefined behaviour, результат, очевидно, 12
11 var a = 5
a = ++a + ++a
println(a)
Тут все проще, конечно, 14
13Больше примеров
var b = 5
b = ++b + b++
println(b)
Банальная логика говорит, что ответ должен быть между 11 и 13
да, 12 var c = 5
c = c++ + ++c
println(c)
От перестановки мест слагаемых сумма не меняется
разумеется, 12 var d = 5
d = d + d++ + ++d + ++d
println(d)
var e = 5
e = ++e + ++e + e++ + e
println(e)
От перестановки мест слагаемых сумма не меняется!
Разумеется:
25
28
Чё там в Scala?
Ничего интересного, в Scala инкрементов нет. Компилятор скажет, что нет метода ++ для Int. Но если очень захотеть, его, конечно, можно определить.2. Одобренный способ
val foo: Int? = null
val bar = foo!! + 5
Что хотели, то и получили
Exception in thread "main" kotlin.KotlinNullPointerException
В документации говорится, что так делать стоит только если вы очень хотите получить NullPointerException. Это хороший метод выстрелить себе в ногу: !! режет глаз и при первом взгляде на код все понятно. Разумеется, использование !! предполагается тогда, когда до этого вы проверили значение на null и smart cast по какой-нибудь причине не сработал. Или когда вы почему-то уверены, что там не может быть null.
Чё там в Scala?
val foo: Option[Int] = None
val bar = foo.get + 5
Что хотели, то и получили
Exception in thread "main" java.util.NoSuchElementException: None.get
3. Переопределение invoke()
Начнем с простого: что делает этот кусок кода и какой тип у a?
class A(){...}
val a = A()
На глупый вопрос - глупый ответ
Правильно, создает новый объект типа A, вызывая конструктор по умолчанию.А здесь что будет?
class В private constructor(){...}
val b = B()
Ну, наверно, ошибка компиляции будет...
А вот и нет!
class B private constructor(){
var param = 6
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke() = B(7)
}
}
Для класса может быть определена фабрика. А если бы она была в классе A, то там все равно вызывался бы конструктор.
Теперь вы ко всему готовы:
class С private constructor(){...}
val c = C()
Тут создается объект класса С через фабрику, определенную в объекте-компаньоне класса С.
Конечно же нет!
class C private constructor(){
...
companion object{
operator fun invoke() = A(9)
}
}
У переменной c будет тип A. Заметьте, что A и С не связаны родственными узами.
Полный код
class A(){
var param = 5
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke()= A(10)
}
}
class B private constructor(){
var param = 6
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke() = B(7)
}
}
class C private constructor(){
var param = 8
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke() = A(9)
}
}
class D(){
var param = 10
private constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke(a: Int = 25) = D(a)
}
}
fun main(args: Array<String>) {
val a = A()
val b = B()
val c = C()
val d = D()
println("${a.javaClass}, ${a.param}")
println("${b.javaClass}, ${b.param}")
println("${c.javaClass}, ${c.param}")
println("${d.javaClass}, ${d.param}")
}
Результат выполнения:
class A, 5
class B, 7
class A, 9
class D, 10
К сожалению, придумать короткий пример, где у вас реально все поломается, я не смог. Но пофантазировать немного можно. Если вы вернете левый класс, как в примере с классом C, то скорее всего, компилятор вас остановит. Но если вы никуда не передаете объект, то можно сымитировать утиную типизацию, как в примере. Ничего криминального, но человек, читающий код, может сойти с ума и застрелиться, если у него не будет исходника класса. Если у вас есть наследование и функции для работы с базовым классом (Animal), а invoke() от одного наследника (Dog) вернет вам другого наследника (Duck), то тогда при проверке типов (Animal as Dog) вы можете накрякать себе беду.
Чё там в Scala?
В Scala проще - есть new, который всегда вызывает конструктор. Если не будет new
, то всегда вызывается метод apply
у компаньона (который тоже может вернуть левый тип). Разумеется, если что-то вам не доступно из-за private
, то компилятор ругнется. Все то же самое, только очевиднее.
4. lateinit
class SlowPoke(){
lateinit var value: String
fun test(){
if (value == null){ //компилятор здесь говорит, что проверка не нужна (и правильно делает)
println("null")
return
}
if (value == "ololo")
println("ololo!")
else
println("alala!")
}
}
SlowPoke().test()
Результат предсказуем
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property value has not been initialized
А как правильно?
class SlowBro(){
val value: String? = null
fun test(){
if (value == null) {
println("null")
return
}
if (value == "ololo")
println("ololo!")
else
println("alala!")
}
}
SlowBro().test()
Результат
null
Я бы сказал, что это тоже одобренный способ, но при чтении кода это неочевидно, в отличие от !!. В документации немного завуалированно говорится, что, мол, проверять не надо, если что, мы кинем тебе Exception. По идее, этот модификатор используется тогда, когда вы точно уверены, что поле будет инициализированно кем-то другим. То есть никогда. По моему опыту, все поля, которые были lateinit
, рано или поздно стали Nullable. Неплохо это поле вписалось в контроллер JavaFX приложения, где GUI грузится из FXML, но даже это “железобетонное” решение было свергнуто после того, как появился альтернативный вариант без пары кнопок. Один раз так получилось, что в SceneBuilder изменил fx:id
, а в коде забыл. В первые дни кодинга на Kotlin немного взбесило, что нельзя сделать lateinit
Int. Я могу придумать, почему так сделали, но сомневаюсь, что совсем нет способа обойти эти причины (читай: сделать костыль).
Чё там в Scala?
А там аналога lateinit
как такового и нет. По крайней мере, я не обнаружил.
5. Конструктор
class IAmInHurry(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = 10
/*tons of code*/
fun initSecondParam(): Int{
println("Initializing by default with $twentySecondParam")
return twentySecondParam
}
}
class IAmInHurryWithStrings(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = "Default value of param"
/*tons of code*/
fun initSecondParam(): String{
println("Initializing by default with $twentySecondParam")
return twentySecondParam
}
}
fun main(args: Array<String>){
IAmInHurry()
IAmInHurryWithStrings()
}
Результат
Initializing by default with 0
Initializing by default with null
Все просто - к полю идет обращение до того, как оно было инициализировано. Видимо, тут стоит немного доработать компилятор. По идее, если вы пишете код хорошо, такая проблема у вас не должна возникнуть, но всякое бывает, не с потолка же я взял этот пример (коллега себе так выстрелил в ногу, случайно через цепочку методов в редко срабатывающем коде вызвал поле, которое было не инициализировано).
Чё там в Scala?
Все то же самое.
object Initializer extends App{
class IAmInHurry(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = 10
/*tons of code*/
def initSecondParam(): Int = {
println(s"Initializing by default with $twentySecondParam")
twentySecondParam
}
}
class IAmInHurryWithStrings(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = "Default value of param"
/*tons of code*/
def initSecondParam(): String = {
println(s"Initializing by default with $twentySecondParam")
twentySecondParam
}
}
override def main(args: Array[String]){
new IAmInHurry()
new IAmInHurryWithStrings()
}
}
Результат
Initializing by default with 0
Initializing by default with null
6. Взаимодействие с Java
Для выстрела тут простор достаточно большой. Очевидное решение - считать все, что пришло из Java, Nullable. Но тут есть долгая и поучительная история. Как я понял, она связана в основном с шаблонами, наследованием, и цепочкой Java-Kotlin-Java. И при таких сценариях приходилось делать много костылей, чтобы заработало. Поэтому решили от идеи “все Nullable” отказаться. Но вроде как один из основных сценариев — свой код пишем на Kotlin, библиотели берем Java (как видится мне, простому крестьянину-кодеру). И при таком раскладе, лучше безопасность в большей части кода и явные костыли в небольшой части кода, которые видно, чем “красиво и удобно” + внезапные грабли в рантайме (или яма с кольями, как повезет). Но у разработчиков другое мнение:
Одна из основных причин была в том, что писать на таком языке было неудобно, а читать его — неприятно. Повсюду вопросительные и восклицательные знаки, которые не очень-то помогают из-за того, что расставляются в основном, чтобы удовлетворить компилятор, а не чтобы корректно обработать случаи, когда выражение вычисляется в null. Особенно больно в случае дженериков: например, MapСделаем небольшой класс на Java:?.
public class JavaCopy {
private String a = null;
public JavaCopy(){};
public JavaCopy(String s){
a = s;
}
public String get(){
return a;
}
}
И попробуем его вызвать из Kotlin:
fun printString(s: String) {
println(s)
}
val j1 = JavaCopy()
val j1Got = j1.get()
printString(j1Got)
Результат
Exception in thread "main" java.lang.IllegalStateException: j1Got must not be null
Тип у j1 - String!
и исключение мы получим только тогда, когда вызовем printString
. Ок, давайте явно зададим тип:
val j2 = JavaCopy("Test")
val j3 = JavaCopy(null)
val j2Got: String = j2.get()
val j3Got: String = j3.get()
printString(j2Got)
printString(j3Got)
Результат
Exception in thread "main" java.lang.IllegalStateException: j3.get() must not be null
Все логично. Когда мы явно указываем, что нам нужен NotNullable, тогда и ловим исключение. Казалось бы, указывай у всех переменных Nullable, и все будет хорошо. Но если делать так:
kotlinprintString(j2.get())
то ошибку вы можете обнаружить нескоро.
Чё там в Scala?
Никаких гарантий, NPE словить можно элементарно. Решение - оборачивать все в Option
, у которого, напомню, есть хорошее свойство, что Option(null) = None
. С другой стороны, тут нет иллюзий, что java interop безопасен.
7. infix нотация и лямбды
Сделаем цепочку из методов и вызовем ее:
fun<R> first(func: () -> R): R{
println("calling first")
return func()
}
infix fun<R, T> R.second(func: (R) -> T): T{
println("calling second")
return func(this)
}
first {
println("calling first body")
}
second {
println("calling second body")
}
Результат
calling first
calling first body
Oops!
calling second body
Подождите-ка… тут какая-то подстава! И правда, “забыл” один метод вставить:
fun<T> second(func: () -> T): T{
println("Oops!")
return func()
}
И чтобы заработало “как надо”, нужно было написать так:
first {
println("calling first body")
} second {
println("calling second body")
}
Результат
calling first
calling first body
calling second
calling second body
Всего один перенос строки, который легко при переформатировании удалить/добавить переключает поведение. Основано на реальных событиях: была цепочка методов “сделай в background” и “потом сделай в ui треде”. И был метод “сделай в ui” с таким же именем.
Чё там в Scala?
Синтаксис немного отличается, поэтому так просто тут себе не выстрелишь:
object Infix extends App{
def first[R](func: () => R): R = {
println("calling first")
func()
}
implicit class Second[R](val value: R) extends AnyVal{
def second[T](func: (R) => T): T = {
println("calling second")
func(value)
}
}
def second[T](func: () => T): T = {
println("Oops!")
func()
}
override def main(args: Array[String]) {
first { () =>
println("calling first body")
} second { () => //<--------type mismach
println("calling second body")
}
}
}
Зато, пытаясь подогнать скаловский код хотя бы для неочевидности засчет implicit/underscore, я взорвал все вокруг.
Осторожно! Кровь, кишки и расчлененка...
object Infix2 extends App{
def first(func: (Unit) => Unit): Unit = {
println("calling first")
func()
}
implicit class Second(val value: Unit) extends AnyVal{
def second(func: (Unit) => Unit): Unit = {
println("calling second")
func(value)
}
}
def second(func: (Unit) => Unit): Unit = {
println("Oops!")
func()
}
override def main(args: Array[String]) {
first { _ =>
println("calling first body")
} second { _ =>
println("calling second body")
}
}
}
И результат:
Exception in thread "main" java.lang.VerifyError: Operand stack underflow
Exception Details:
Location:
Infix2$Second$.equals$extension(Lscala/runtime/BoxedUnit;Ljava/lang/Object;)Z @40: pop
Reason:
Attempt to pop empty stack.
Current Frame:
bci: @40
flags: { }
locals: { 'Infix2$Second$', 'scala/runtime/BoxedUnit', 'java/lang/Object', 'java/lang/Object', integer }
stack: { }
Bytecode:
0000000: 2c4e 2dc1 0033 9900 0904 3604 a700 0603
0000010: 3604 1504 9900 4d2c c700 0901 5701 a700
0000020: 102c c000 33b6 0036 57bb 0038 59bf 3a05
0000030: b200 1f57 b200 1fb2 001f 57b2 001f 3a06
0000040: 59c7 000c 5719 06c6 000e a700 0f19 06b6
0000050: 003c 9900 0704 a700 0403 9900 0704 a700
0000060: 0403 ac
Stackmap Table:
append_frame(@15,Object[#4])
append_frame(@18,Integer)
same_frame(@33)
same_locals_1_stack_item_frame(@46,Null)
full_frame(@77,{Object[#2],Object[#27],Object[#4],Object[#4],Integer,Null,Object[#27]},{Object[#27]})
same_frame(@85)
same_frame(@89)
same_locals_1_stack_item_frame(@90,Integer)
chop_frame(@97,2)
same_locals_1_stack_item_frame(@98,Integer)
at Infix2$.main(Infix.scala)
8. Перегрузка методов и it
Это, скорее, метод подгадить другим. Представьте, что вы пишете библиотеку, и в ней есть функция
fun applier(x: String, func: (String) -> Unit){
func(x)
}
Разумеется, народ ее использует довольно прозрачным способом:
applier ("arg") {
println(it)
}
applier ("no arg") {
println("ololo")
}
Код компилируется, работает, все довольны. А потом вы добавляете метод
fun applier(x: String, func: () -> Unit){
println("not applying $x")
func()
}
И чтобы компилятор не ругался, пользователям придется везде отказаться от it
(читай: переписать кучу кода):
applier ("arg") { it -> //FIXED
println(it)
}
applier ("no arg") { -> //yes, explicit!
println("ololo")
}
Хотя, теоретически, компилятор мог бы и угадать, что если есть it
, то это лямбда с 1 входным аргументом. Думаю, что с развитием языка и компилятор поумнеет, и этот пункт — временный.
Чё там в Scala?
Без аргументов придется явно указать, что это лямбда. А при добавлении нового метода поведение не изменится.
object Its extends App{
def applier(x: String, func: (String) => Unit){
func(x)
}
def applier(x: String, func: () => Unit){
println("not applying $x")
func()
}
override def main(args: Array[String]) {
applier("arg", println(_))
applier("no arg", _ => println("ololo"))
}
}
9. Почему не стоит думать о Nullable как об Option
Пусть у нас есть обертка для кэша:
class Cache<T>(){
val elements: MutableMap<String, T> = HashMap()
fun put(key: String, elem: T) = elements.put(key, elem)
fun get(key: String) = elements[key]
}
И простой сценарий использования:
val cache = Cache<String>()
cache.put("foo", "bar")
fun getter(key: String) {
cache.get(key)?.let {
println("Got $key from cache: $it")
} ?: println("$key is not in cache!")
}
getter("foo")
getter("baz")
Результат довольно предсказуем
Got foo from cache: bar
baz is not in cache!
Но если мы вдруг захотим к кэше хранить Nullable…
val cache = Cache<String?>()
cache.put("foo", "bar")
fun getter(key: String) {
cache.get(key)?.let {
println("Got $key from cache: $it")
} ?: println("$key is not in cache!")
}
getter("foo")
getter("baz")
cache.put("IAmNull", null)
getter("IAmNull")
То получится не очень хорошо
Got foo from cache: bar
baz is not in cache!
IAmNull is not in cache!
Зачем хранить null
? Например, чтобы показать, что результат не вычислим. Конечно, тут было бы правильнее использовать Option
или Either
, но, к сожалению, ни того, ни другого в стандартной библиотеке нет (но есть, например, в funKTionale). Более того, как раз при реализации Either
, я наступил на грабли этого пункта и предыдущего. Решить эту проблему с “двойным Nullable” можно, например, возвратом Pair
или специального data class
.
Чё там в Scala?
Никто не запретит сделать Option от Option. Надеюсь, понятно, что так все будет хорошо. Да и с null
тоже:
object doubleNull extends App{
class Cache[T]{
val elements = mutable.Map.empty[String, T]
def put(key: String, elem: T) = elements.put(key, elem)
def get(key: String) = elements.get(key)
}
override def main(args: Array[String]) {
val cache = new Cache[String]()
cache.put("foo", "bar")
def getter(key: String) {
cache.get(key) match {
case Some(value) => println(s"Got $key from cache: $value")
case None => println(s"$key is not in cache!")
}
}
getter("foo")
getter("baz")
cache.put("IAmNull", null)
getter("IAmNull")
}
Все хорошо
Got foo from cache: bar
baz is not in cache!
Got IAmNull from cache: null
10. Объявление методов
Бонус для тех, кто раньше писал на Scala. Спонсор данного пункта - @lgorSL. Цитирую:
...
Или, например, синтаксис объявления метода:В scala:
def methodName(...) = {...}
В kotlin возможны два варианта — как в scala (со знаком
=
) и как в java (без него), но эти два способа объявления неэквивалентны друг другу и работают немного по-разному, я однажды кучу времени потратил на поиск такой “особенности” в коде.
….
Я подразумевал следующее:fun test(){ println("it works") } fun test2() = println("it works too") fun test3() = {println("surprise!")}
Чтобы вывести “surprise”, придётся написать
test3()()
. Вариант вызоваtest3()
тоже нормально компилируется, только сработает не так, как ожидалось — добавление “лишних” скобочек кардинально меняет логику программы.Из-за этих граблей переход со скалы на котлин оказался немного болезненным — иногда “по привычке” в объявлении какого-нибудь метода пишу знак равенства, а потом приходится искать ошибки.
Заключение
На этом список наверняка не исчерпывается, поэтому делитесь в комментариях, как вы шли дорогой приключений, но потом что-то пошло не так…
У языка много положительных черт, о которых вы можете прочитать на официальном сайте, в статьях на хабре и еще много где. Но лично я не согласен с некоторыми архитектурными решениями (классы final by default, java interop) и иногда чувствуется, что языку нехватает единообразия, консистентности. Кроме примера с lateinit
Int приведу еще два. Внутри блоков let
используем it
, внутри with
- this
, а внутри run
, который является комбинацией let
и this
что надо использовать? А у класса String!
можно вызвать методы isBlank()
, isNotBlank()
, isNullOrBlank()
, а “дополняющего” метода вроде isNotNullOrBlank
нет:( После Scala нехватает некоторых вещей - Option
, Either
, matching, каррирования. Но в целом язык оставляет приятное впечатление, надеюсь, что он продолжит достойно развиваться.
P.S. Хабровская подсветка Kotlin хромает, надеюсь, что администрация @habrahabr это когда-нибудь поправит…
UPD: Выстрелы от комментаторов (буду обновлять)
Неочевидный приоритет оператора elvis. Автор - @senia.
UPD2
Обратите еще внимание на статью Kotlin: без Best Practices и жизнь не та.
В комментариях там есть еще один шикарный выстрел от @Artem_zin: возможность переопределить get()
у val
и возвращать значения динамически.
UPD3
Еще некоторые новички могут подумать, что операторы and
и or
для булевых переменных - это такой сахар для “нечитаемых” &&
и ||
. Однако это не так: хоть результат вычислений будет тем же, но “старые” операторы вычисляются лениво. Поэтому если вы вдруг напишете так:
if ((i >= 3) and (someArray[i-3] > 0)){
//do something
}
то получите исключение при i<3
.
Скрин опроса:
О стилистике ответов на форумах
Ему про Ерему, а он про Фому.
Довольно часто сталкиваюсь на форумах с ситуацией, когда какой-то человек (иногда это я) просит о помощи в настройке/установке/чем-нибудь, а ему, не отвечая на исходный вопрос, пытаются высказать свое мнение. Хочу обратить внимание людей на данную проблему. Для наглядности поясню на отстраненном от IT-области примере.
Спрашивает человек на форуме: «А где бы купить козьего молочка в родной деревеньке Гадюкино?» А ему отвечают: «Не, козье вредно — ты лучше нормального, коровьего, купи, оно лучше!» «Да-да! Козье — гадость полная, на вкус противное, мне не нравится, пей коровье!» «Не покупай в Гадюкино! В Верхних Валенках гораздо вкуснее продают!»
Знакомая ситуация? Если нет, дальше читать не имеет смысла.
Конечно, иногда эти советы правильные, но все же, человек спрашивал не про то, какое молоко полезнее или где вкуснее. Может так судьба сложилась, что ему нужно именно так, и никак иначе, может они и сам не рад, что так получилось. Особенно раздражает ситуация, когда человек пишет дополнительно, что нужно именно это, он рабирается во вкусности/полезности, и не надо писать свои советы по данному поводу. Но даже в этом случае находятся советчики, которым важнее высказать свое мнение, чем реально помочь человеку.
Лишь единицы ответят что-то вроде: «У бабы Нюры на рынке можно купить, в третьем ряду».
И совсем малое количество скажет: «Можно еще у тетки Лиды купить, в четвертом ряду. Но я бы все-таки рекомендовал в Верхних Валенках купить, если есть возможность, потому что Нюра с Лидой могут и кислое продать».
На мой взгляд, надо придерживаться последнего варианта. Во-первых, таким образом человеку все-таки оказывается действительная помощь в решении его проблемы. Во-вторых, высказано личное мнение по данному вопросу. И в-третьих, ваше мнение аргументированно, заставляет задуматься здравомыслящего человека и не навязывается. Обратите снова внимание на первые коментарии: «пей», «купи», «не покупай». Глаголы в повелительном наклонении навязывают чужое мнение, не оставляют другого выбора.
Думаю, многие сталкивались с подобными ситуациями и им не составит труда вспомнить какой-нибудь пример из своей жизни.
Я не настаиваю на том, чтобы не говорить свое мнение, просто постарайтесь формулировать его в менее аксиоматичной форме.
Devil's Pie
Первая моя более-менее полезная статья. В ней отражено мое тогдашнее увлечение настройкой линукса под себя. Сидел часами, пытаясь решить как банальные проблемы, которых в те времена было много, так и очень специфичные. Например, эта статья родилась из-за того, что Counter-Strike, запущенная через wine, в полноэкранном режиме работала не так, как мне нравилось: долго переключаться было на аську, и куб рабочих столов лагал. А в оконном режиме мешали заголовки окна. Другие примеры — тоже исправление того, что мне не нравилось.
Эту статью я изначально писал на хабр, но там она не вышла из песочницы и инвайт я не получил. Но я решил написать одному из администраторов русскоязычного сообщества Ubuntu, Malamut. И в итоге получилсь wiki-страница на help.ubuntu.ru, а я получил от Malamut инвайт на хабр.
Я потом еще написал две wiki-страницы: “Возвращаем кнопку переключения адресной строки Nautilus” и “как убрать индикатор раскладки клавиатуры”. Первая еще даже жива, а вторая сгинула. Но их ценность уже сомнительна, а эта — дорога как память.
…
Devil’s Pie — утилита, дополняющая возможности Metacity — стандартного менеджера окон среды GNOME. Как говорит ее разработчик, Ross Burton, на создание Devil’s Pie его вдохновила опция “Matched Windows” старого менеджера, SawFish, и недостаток функционала в Metacity.
Данная утилита позволяет применять к приложениям набор правил по некоторым шаблонам. Например, с ее помощью можно запустить любое приложение в свернутом состоянии, указать его местоположение на экране и номер рабочего стола, не показывать приложение в списке задач и т.п.
Правила
Devil’s Pie работает с набором правил вида
(if a b c)
Здесь а — проверяемое условие, b — выполняемое действие, если условие выполнено, с — если условие не выполнено.
Правила надо хранить в текстовом файле с расширением .ds. Devil’s Pie по умолчанию загружает все такие файлы из папки ~/.devilspie. Создатель рекомендует хранить по одному правилу в файле, однако можно использовать объединение конструкций и хранить несколько правил в одном файле. Можно запускать Devil’s Pie, используя только определенные файлы правил, прописывая их в параметрах командной строки.
Условия
В качестве условия может выступать одна из следующих конструкций:
(is a b)
Выполняется, когда строка а совпадает со строкой b.
(contains a b)
Выполняется, когда строка b является подстрокой a.
(matches a b)
Выполняется, когда строка а соотвествует регулярному выражению b. Пример:
(matches "ubuntu" "[bntu]{6}") — true
(matches "ubuntu" "[0-9]+") — false
Условия можно усложнять с помощью операторов and, or, not (используется префиксная нотация). Пример:
(or (and a (not b)) c d)
Выражение будет истинным, когда будут истинны либо c, либо d, либо одновременно a будет истинно и b — ложно.
Свойства окон
В качестве проверяемых строк можно использовать следующие свойства окон:
window_name — заголовок.
window_role — роль, определенная в WM_WINDOW_ROLE.
application_name — имя приложения.
window_workspace — номер рабочего стола.
window_class — класс окна.
window_xid — XID.
window_property свойство — другие свойства.
Полный список возможных параметров можно посмотреть здесь: http://standards.freedesktop.org/wm-spec/wm-spec-latest.html#id2511080
Действия
Основные действия:
geometry — задает положение и размер окна.
В качестве аргумента используется строка X-GeometryString:
[<width>{xX}<height>][{+-}<xoffset>{+-}<yoffset>]
Пример:
(geometry "400x300+0-22")
fullscreen — перевод в полноэкранный режим.
maximize — развернуть на весь экран.
unmaximize — обратное действие.
minimize — свернуть.
unminimize — развернуть.
close — закрыть окно.
pin — поместить на всех рабочих столах.
set_workspace N — переместить на рабочий стол N.
skip_tasklist — не показывать окно в списке задач.
above — окно будет расположено поверх всех окон.
wintype тип — задает тип окна: "normal", "dialog", "menu", "toolbar", "splashscreen", "utility", "dock","desktop".
opacity N — задает прозрачность в процентах.
spawn_sync сommand — выполняет внешнюю команду.
Некоторые действия можно применить не ко всем типам окон, например диалоговые окна не принимают команду изменения размера. Обойти это ограничение можно с помощью команды wintype "normal"
.
Более полный набор действий можно посмотреть здесь: http://foosel.org/linux/devilspie.
Объединение действий
Действия можно объединять с помощью конструкции
(begin a1 a2 ... an)
Также можно и объединять несколько правил в одном файле:
(begin
(if a b c)
(if d e f)
)
Примеры
Прикрепить спикок контактов Gaim Buddylist ко всем рабочим столам с размером 340×630 пискелей и с положением x=4, y=150.
(if
(and
(is (application_name) "gaim")
(is (window_name) "Buddy List")
)
(begin
(pin)
(geometry "340x630+4+150")
)
)
Переместить firefox на рабочий стол №2 и развернуть окно на весь экран
(if
(is (application_name) "firefox-bin")
(begin
(set_workspace 2)
(maximize)
)
)
Закрыть окно KAlarm при запуске системы.
Создаем в папке ~/.devilspie файл kalarm.ds:
(if
(is (window_name) "KAlarm" )
(begin
(close)
(spawn_sync "bash -c \"ps auxww| grep -E 'devilspie(.*)kalarm' | awk '{print \$2}'| xargs kill\"")
)
)
Добавляем в “Запускаемые приложения” команду
devilspie ~/.devilspie/kalarm.ds
Таким образом, как только Devil’s Pie видит окно KAlarm, он закрывает его (сворачивает в трей) и завершает свою работу.
Развернуть окно Counter-Strike, запущенного под wine, на весь экран.
Создаем в папке ~/.devilspie файл cs.ds:
(if
(matches (window_name) "Counter-Strike")
(begin
(wintype "normal")
(fullscreen)
)
)
Добавляем в “Запускаемые приложения” команду
devilspie ~/.devilspie/cs.ds
Окно диалоговое, и просто так развернуть его во весь экран не получится. Поэтому начала ему назначаем тип. В отличие от первого случая, Devil’s Pie будет постоянно отслеживать окно и не будет завершаться после применения правила.
Ссылки
Одна из самых подробнейших документаций по утилите на английском языке http://foosel.org/linux/devilspie
Сайт разработчика: http://www.burtonini.com/blog/computers/devilspie