2017-12-24

О типизации

Да начнётся срач!
Так получилось, что пару месяцев подряд я интенсивно кодил на Котлине. И учил студентов кодить на Яве.
Один разок я даже устроил воркшоп в стиле TDD, где к своему удивлению обнаружил, что добрая половина ява-кода в IntelliJ IDEA пишется по нажатию Alt+Enter. Это автоисправление ошибок: класс новый создать, который тут упоминается, но ещё отсутствует, метод новый добавить, и в нужные интерфейсы, и в нужные классы, когда этот метод впервые упоминается в тестах.
А другое автодополнение, по Alt+Space, но чаще просто выскакивающее само, после точки после имени переменной, позволяет особо не нагружать свою человеческую память тем, что умеют данные классы. Таким образом тоже «пишется» уйма кода.
А вот неделю назад пришлось расчехлить Питон. Надо было чуток подкрутить-поковырять проектик, который чуть ли не год пылился.
Ну и где моя магия автодополнений? Если что, у меня IDEA Ultimate, и все нужные плугины стоят.
Почему IDEA ничего не знает о существовании функции requests.compat.urljoin, пока я не напишу соответствующий импорт? Почему я, когда смотрю на __init__.py этого самого requests, тоже не вижу никаких compat? Откуда он вообще берётся? Почему в официальной документации requests ничего не сказано про compat? Почему таки использование этого urljoin нахваливают на StackOverflow? Его вообще можно использовать, или это секретное API, которое в любой момент может превратиться в тыкву?
Это лишь один, последний попавшийся, пример. Почему-то в Питоне всегда так. IDE лишь смутно предполагает, что тут сымпортировали, что тут передали, что с этим можно делать. Разработчику нужно либо действовать методом научного тыка, либо писать подробные тесты, либо очень хорошо знать ту систему, которую пишешь, и те библиотеки, которые используешь. Чтобы предполагать несколько больше, чем IDE. А если нужно быстро написать долбаный плугин к совершенно незнакомому фреймворку, вообще чувствуешь себя слепым котёнком. И остаётся только научный тык.
Сладывается у меня подозрение, что это всё из-за динамической типизации. Никто не знает, что тут пришло, лишь бы крякало. Если не крякает, это ваши проблемы, надо было выявить тестами.
Untyped Duck
Тут сразу возражают: ведь весь окружающий мир динамичен. Ведь нам в запросе приходит бог его знает какой JSON, и нам надо его обработать. И в языках с динамической типизацией это делается просто и естественно. А в языках со статической типизацией приходится сначала натягивать этот JSON на какой-то класс. Да и этот класс нужно сначала где-то объявить.
На самом деле, хочется видеть «линзу». Не важно, что там приходит. Но здесь и сейчас я хочу видеть объект с определёнными свойствами. Если нужных мне обязательных свойств нужного типа не пришло — это ошибка. Если пришло, натягиваем на объект, и поехали дальше уже со строгой типизацией.
Собственно, все приличные веб фреймворки так и поступают с входящим JSON. Вопрос только в том, как описать те свойства, которые нам интересны. Можно ява-бобами, можно котлиновыми дата-классами (обожаю их). В нашем секретном фреймворке мы вообще (почти) автоматически натягиваем динамический объект сообщения (а-ля Map) на (почти любой) интерфейс, описывающий ожидаемые свойства этого сообщения.
Написать интерфейс из нескольких геттеров, или написать дата-класс на три строчки — имхо, не большая плата за то, что во всех дальнейших операциях гарантируется наличие этих нужных свойств. Причём гарантия даётся ещё при написании кода. В рантайме сломаться может только маппинг динамического объекта на статическое описание. Ну мы же и не ожидали, что любой JSON на входе нам подойдёт?
Говорят, интерфейсы в Go как раз и делают то, что нужно. С одной стороны есть произвольные структуры. С другой стороны есть интерфейсы, описывающие ожидаемые методы. Если есть функции над структурой, реализующие эти методы, считается, что эта структура реализует этот интерфейс. Поэтому мы пишем интерфейс, указывая, что мы хотим. И пишем функции, указывающие, как из имеющихся данных получить то, что мы хотим.
Static Typing
Вообще вырисовывается три способа передачи развесистых данных в функцию. Передача динамического объекта, вроде мапы или словаря. Передача статического объекта, с заранее (на этапе компиляции) определённым набором полей. Ну и можно выделить именованные параметры, достаточно динамичный способ, тем не менее, задаваемый при написании кода, которым почему-то брезгуют многие языки. А ведь удобно, не зря же в современных API на JavaScript взялись передавать объекты с именованными полями, как единственный аргумент функции.
В языках с динамической типизацией легче передавать динамические объекты. Словари в Питоне, ассоциативные массивы в PHP, объекты (они там все динамические) в ЯваСкрипте. Создавать новые классы тут муторно и лень.
В языках со статической типизацией легче передавать статические объекты. Проще описать класс с нужными свойствами и сконструировать его экземпляр, чем собрать и разобрать какой-нибудь Map.
Наверное, это какой-то заговор. И вот эта лёгкость динамичного в динамичных языках и лёгкость статичного в статических языках как раз и приводит к тому, что языки остаются каждый в своей нише, и не идут навстречу друг к другу.
Duck Typing
А если нам нужны не данные, а поведение?
В Питоне есть такое понятие: file-like object. Попробуйте найти в официальной документации, какие именно методы должны быть у таких объектов. Конечно будет read(). Но ведь не только он?
Утиная типизация. Известно (по крайней мере ожидается), что это утка. Утка должна крякать. Но что такое «крякать»? Документация Питона не всегда даёт ответ на этот вопрос. Интерфейсы же, что Явы, что Гоу, дают чёткий ответ на этот вопрос.
И всё же, я предпочитаю статическую типизацию даже не из-за проверок компилятора, в конце концов тесты в любом случае нужно писать, а из-за того, что в случае, когда IDE знает точный тип, она может дать актуальные подсказки, которые действительно ускоряют работу. Если мерять эту работу не количеством набранных символов (что можно подсчитать по истории коммитов), а количеством нажатых клавиш (что можно подсчитать только локально в IDE).
Впрочем, тут есть и обратная сторона. Видел кучу разработчиков, которые вообще ни разу не открывали документацию того API, который они вовсю используют. Они просто ставят точку, смотрят список методов, который выдаёт IDE, выбирают наболее подходящий (по имени), и используют его. Конечно же они напарываются на неудачные названия методов, всяческие нюансы, вроде ограничений использования данных классов и методов, выбирают не оптимальные решения. Всё равно документацию читать надо. RTFM, как говорится.
Duck Typing
З.Ы. Не уверен, относится ли это к данной дискуссии, но утверждают, что с типами в Питоне вообще всё плохо.

2017-12-09

О Spring

Spring — это весна. Spring — это пружина. Spring — это родник. Springfield — это городок, где живут Симпсоны. Плюс ещё стопицот одноимённых городков в Соединённом Королевстве, Австралии и Соединённых Штатах.
А ещё есть Spring Framework. Фреймворк, который знают все явисты. Возникший когда-то как легковесная альтернатива Ынтырпрайзным ЯваБобам (EJB).
Spring Logo
Помню, как лет десять назад мы перелезали на Спринг с таких, ныне экзотичных вещей, как Apache Struts, или даже просто месива из сервлетов и JSP. Тогда надо было писать большущие простыни XMLя с описанием всех бинов. Уже тогда надо было подглядывать в исходники Спринга, чтобы понять, как туда воткнуть что-то нестандартное с точки зрения разработчиков фреймворка.
Сейчас в Спринге есть аннотации. И конфигурацию можно описывать Ява кодом. Или вообще не описывать, а просто помечать нужные классы как @Component.
Сейчас есть Spring Boot. Вообще крайне странная штуковина.
С одной стороны, он сильно упрощает начальное создание Спринг приложения. Это — POM артефакты, содержащие целые группы спринговых и внешних зависимостей, с тщательно подобранными (хочется в это верить) версиями, гарантированно работающими друг с другом. Это — автоматические настройщики, которые создают бины, исходя из набора свойств в application.yml или application.properties, и сканируют пакеты в поисках классов и методов помеченных хитрыми аннотациями, чтобы и их настроить. Это — плугины к Maven и Gradle, которые умеют собирать красивые суперджары, с внедрённым Jetty или Netty для веб приложений.
С другой стороны, Spring Boot весьма усложняет попытки уйти в сторону и сделать что-то непредусмотренное. Притащить другую версию библиотеки почти наверняка не выйдет, потому что у артефакта Spring Boot уже есть своя версия. Причём какая это версия, простого способа узнать нет, ибо там очень много транзитивных зависимостей. Чтобы воткнуть нестандартную конфигурацию, банально несколько подключений к разным MongoDB, придётся отключать автоконфигураторы. Какие именно отключать, придётся находить методом научного тыка, ибо нет простого списка этих конфигураторов с описанием того, за что они отвечают и что делают.
Spring Boot Logo
В тех случаях, когда наш любимый, акторный, но пока не очень человеколюбивый фреймворк использовать нельзя, мы берём Spring, Spring Boot и Kotlin. И нормально.
Точка входа в приложение, Application.kt, выглядит забавно.
@SpringBootApplication
@EnableAutoConfiguration(exclude=arrayOf(MongoAutoConfiguration::class, MongoDataAutoConfiguration::class))
@EnableScheduling
@EnableCaching
open class Application

fun main(args: Array<String>) {
    SpringApplication.run(Application::class.java, *args)
}
main() тут получается в классе ApplicationKt. Но Спрингу и его Буту нужен ещё один класс, открытый, ибо станет бином. И вот этот класс и становится @SpringBootApplication.
Современный Спринг делает страшные вещи с вашими классами. То, что будет бином, т.е. любые классы, помеченные @SpringBootApplication, @Configuration, @Component, @Repository, @Service, @Controller, а также любые методы, помеченные @Bean, в Котлине должны быть open. Потому что вы (почти) никогда не получите на выходе (в IOC) именно ваш класс. Вы получите его наследника. Куда будут аккуратно засунуты все объявленные @Autowired и @Value.
Любой класс, куда вы навешаете эти аннотации, станет частью Спринга. Вам понадобятся спринговые зависимости, чтобы эти аннотации объявить. И если этот класс случайно окажется в пакете, где его найдёт автоконфигурация, он сразу окажется в IOC. А если не получится найти все нужные @Autowired и @Value, то приложение рухнет на старте.
Поэтому мы так не делаем. Мы стараемся делать сервисы и компоненты, которые вообще не зависят от Спринга. Все другие сервисы и компоненты, а также конфигурационные значения, которые нужны для работы этого сервиса, передаются в конструкторе. Как это и положено с нормальными объектами. Куча аргументов конструктора в Котлине — не проблема. Мы просто используем именованные аргументы.
Сами внешние сервисы и компоненты в аргументах конструктора представлены интерфейсами. Интерфейс легко замокать. И можно и нужно протестировать этот компонент как положено, юнит тестами. А ещё типы бинов удобнее представлять интерфейсами, тогда можно тихо и незаметно подменить реализацию. Кстати, Спринг вполне корректно различает генерик интерфейсы, с разными типами переменных типа.
class EntityInsertService(
    private val name: String = "",
    private val mongoOps: MongoOperations,
    private val collectionName: String,
    private val executor: Executor
) : IInsertService<Entity> {

    override fun insert(data: Entity) {
        //...
    }

    override fun flush() {
        //...
    }

}
Такие компоненты без аннотаций легко выносятся в (почти) независимые от Спринга библиотеки и переиспользуются между разными приложениями.
А в самом приложении они уже подключаются через объявление конфигурации.
@Configuration
open class InsertServiceConfiguration {

    @Value("\${insert.concurrency:2}")
    private var concurrency: Int = 2

    @Value("\${insert.collection:data}")
    private lateinit var collection: String

    @Autowired
    private lateinit var mongo: MongoOperations

    @Bean
    open fun insertService(): IInsertService<Entity> {
        val executor = Executors.newWorkStealingPool(concurrency)
        return EntityInsertService(
            name = "entityInsert",
            mongoOps = mongo,
            collectionName = collection,
            executor = executor
        )
    }

}
Kotlin Logo
Вот эти вот insert.concurrency — это проперти приложения. Как известно, их можно объявлять в application.properties. Но мы, конечно же, предпочитаем более сложновложенный application.yml. Из этих пропертей тоже можно конструировать бины, списки и даже мапы.
Вот вам нестандартная конфигурация с кучей Монг:
mongodb:
  connections:
    source:
      host: 172.31.22.180
      port: 27017
      database: project
      serverSelectionTimeout: 10000     # https://scalegrid.io/blog/understanding-mongodb-client-timeout-options/
      connectTimeout: 5000
      socketTimeout: 1000
    target:
      host: localhost
      port: 27017
      database: test
      serverSelectionTimeout: 10000
      connectTimeout: 5000
      socketTimeout: 1000
      writeConcern: acknowledged        # http://mongodb.github.io/mongo-java-driver/3
Можно, конечно, наваять @Configuration класс, куда внедрить все эти значения через @Value. Но мне понадобилось иметь произвольное количество таких подключений. Их все можно прочитать в мапу с помощью @ConfigurationProperties.
data class MongoConnectionProperties(
    var host: String = "localhost",
    var port: Int = 27017,
    var database: String = "test",
    var serverSelectionTimeout: Int = 0,
    var connectTimeout: Int = 5000,
    var socketTimeout: Int = 1000,
    var writeConcern: String = "acknowledged",
    var connectionsPerHost: Int = 10
) {
    val writeConcernValue: WriteConcern
        get() = WriteConcern.valueOf(writeConcern.toUpperCase())
}

data class MongoConnections(
    val connections: MutableMap<String, MongoConnectionProperties> = mutableMapOf()
) {
    operator fun get(name: String): MongoConnectionProperties
        = connections[name] ?: throw NoSuchElementException("No MongoDB connection mongodb.connections.$name")
}

@Configuration
open class MongoConnectionsConfiguration {

    @Bean
    @ConfigurationProperties(prefix="mongodb")
    open fun allMongoProperties(): MongoConnections {
        return MongoConnections()
    }

}
Эта мапа должна быть мутабельной, а объекты должны быть настоящими ява бинами, с полным набором сеттеров, чтобы Спринг смог эту мапу заполнить. Поэтому тут в котлиновых датаклассах сплошные var и дефолтные значения. Для итогового набора свойств тоже нужно придумывать свой класс, нельзя просто вернуть коллекцию, потому что списки и мапы в качестве типа бина обрабатываются Спрингом по-особому: он пытается туда впихнуть все бины из контекста, подходящие по типу, а нам это совсем не нужно.
Из бина описания можно сделать настоящий объект MongoTemplate, который уже можно использовать.
@Configuration
open class MongoConnectionsConfiguration {

    @Bean
    @Scope("prototype")
    open fun mongoOperations(name: String): MongoOperations {
        val properties = allMongoProperties()[name]
        val options = MongoClientOptions.builder()
            .writeConcern(properties.writeConcernValue)
            .serverSelectionTimeout(properties.serverSelectionTimeout)
            .connectTimeout(properties.connectTimeout)
            .socketTimeout(properties.socketTimeout)
            .connectionsPerHost(properties.connectionsPerHost)
            .build()
        val dbFactory = SimpleMongoDbFactory(
            MongoClient(ServerAddress(properties.host, properties.port), options),
            properties.database)
        return MongoTemplate(dbFactory)
    }

}
Правда здесь у нас получается бин, который прототип, да ещё принимающий строку параметром. Получить такие бины из ApplicationContext труда не составляет. А вот @Autowired или @Inject для них уже не работают.
Поэтому приходится, если нужно, все эти прототипы создавать явно, и засовывать в синглетон коллекцию. Опять своего отдельного типа. Славься Котлин дата классами.
data class MongoOperationsMap(
    val mongos: Map<String, MongoOperations>
)

@Configuration
open class MongoConnectionsConfiguration {

    @Bean
    open fun allMongoOperations() : MongoOperationsMap {
        val map: MutableMap<String, MongoOperations> = mutableMapOf()
        for ((name, _) in allMongoProperties().connections) {
            map.put(name, mongoOperations(name))
        }
        return MongoOperationsMap(map)
    }

}
Ну а дальше этот наш дата класс можно автовайрить куда угодно.
Springfield
Иногда приходится бороться со Спрингом вообще, и со Спринг Бутом в частности. И хочется выкинуть этот Спринг нафиг. Со всеми его странными и несовместимыми обёртками вокруг обычного Монго драйвера.
Но как представишь, сколько вопросов возникнет без Спринга: Где создавать компоненты? Как их связывать друг с другом? Как автоматически и многопоточно подключаться к какой-нибудь очереди? Какой шедулер взять? Какой кэш взять и как его прикрутить?
И решаешь: пусть Спринг остаётся. Всё же он берёт на себя громадную кучу инфраструктурных проблем. А иногда с ним пободаться даже полезно.

2017-11-28

О Котлине

Тихо и незаметно вот уже второй настоящий коммерческий проект пишем с нуля и полностью на Котлине. И это ещё не считая всякой персональной мелочи под Андроид. Тихо и незаметно Котлин стал моим основным языком. Весь новый код по возможности стараюсь писать на Котлине.
А ещё три года назад всё выглядело не так однозначно. Была старушка Scala. Был странный Ceylon. И был подающий надежды, но с непонятным будущим, Kotlin.
В мире Scala не всё так однозначно. Typesafe стала Lightbend, и для всех их Scala продуктов заявлена поддержка отличного Java API. Scala перестал считать себя супер-языком, и стремится выжить в мире JVM.
Про Ceylon вообще ничего не слышно. Википедия говорит, новые версии выпускались стабильно, а в августе 2017 его передали в Eclipse Foundation.
А вот Kotlin расцвёл. Его настолько сильно продвигали в сторону Android разработки, что в результате его назначили first-class supported language для Android. И это хорошо. А ещё появился Kotlin/Native, который позиционируют для разработки под iOS. А ещё появляется всё больше чисто котлиновых библиотек, которые вовсю используют плюшки языка. А ещё в Котлине 1.1 добавили (пока экспериментальную) весьма интересную поддержку корутин. Минимальными изменениями в самом языке, без введения кучи новых ключевых слов, стало можно делать библиотеки, реализующие всякую асинхронщину вроде async/await, генераторов и всего такого.
В общем, пора писать на Котлине, если вы ещё не начали писать на Котлине. Котлин — прекрасен. Хотя не все с этим ещё согласны.
Kotlin logo (latest)
Объясняешь студентам: State — это такой класс, от которого нужно, чтобы его экземпляры были различными, в той степени, в которой нужно. Поэтому достаточно в нём иметь лишь одно строковое поле name. Но также нужно переопределить метод equals(), чтобы корректно сравнивать, и метод hashCode(), чтобы использовать в качестве ключа HashMap, и метод toString(), для удобства.
То есть в Яве нужно вот так:
public final class State {

    private final String name;

    public State(String name) {
        this.name = name;
    }

    public boolean equals(Object object) {
        if (this == object) return true;
        if (!(object instanceof State)) return false;
        if (!super.equals(object)) return false;

        State state = (State) object;

        if (name != null ? !name.equals(state.name) : state.name != null) return false;

        return true;
    }

    public int hashCode() {
        int result = super.hashCode();
        result = 31 * result + (name != null ? name.hashCode() : 0);
        return result;
    }

    public String toString() {
        return "State{" +
                "name='" + name + '\'' +
                '}';
    }

}
А в Котлине для всего этого достаточно лишь:
data class State(
    private val name: String
)
Это дата классы. Они финальные. Из них нельзя построить красивую иерархию наследования. Но это и не нужно. Они идеально подходят для передачи и представления данных. Вот структуры и всё.
data class SampleData(
    val visibleReadOnly: String,
    private val invisibleReadOnly: String,
    var readWrite: String
) {
    val internalReadOnly = invisibleReadOnly.toUpperCase()
}

val instance = SampleData(
        visibleReadOnly = "A",
        invisibleReadOnly = "B",
        readWrite = "C"
)

instance.visibleReadOnly    // "A"
//instance.visibleReadOnly = "a"  // compilation failure
//instance.invisibleReadOnly  // compilation failure
instance.readWrite  // "C"
instance.readWrite = "c"
instance.internalReadOnly   // "B"
//instance.internalReadOnly = "b" // compilation failure
Большинство свойств нашего класса данных нужно задавать в конструкторе. Наличие именованных параметров позволяет делать конструкторы принимающими сколь угодно много аргументов. И это остаётся удобным. Просто всегда вызывайте конструкторы с именованными аргументами.
А ещё есть и дефолтные значения параметров. Это чертовски запутывает, если пользоваться старыми добрыми позиционными параметрами. Но если пользоваться именованными — опять всё ок.
Кроме параметров конструктора, можно задать и обычные проперти класса. Но тогда имеет смысл делать эти проперти чем-то производным от параметров конструктора.
Ещё можно и методы добавлять, не проблема. Но, как правило, это не нужно.
Если у вас на всех пропертях стоит val, вы получите честную иммутабельность. Задаёте все свойства в конструкторе, а потом можете их читать. Вполне солидно.
Но ещё есть метод copy(), который позволяет «мутировать» ваши иммутабельные дата классы. Он принимает тот же набор параметров, что и конструктор, только все с дефолтными значениями. И создаёт копию дата-объекта, с изменёнными свойствами.
val copy = instance.copy(visibleReadOnly = "AAA")
Именованные аргументы, да ещё и с дефолтными значениями — вообще хорошо. Иногда создаётся иллюзия, будто кодишь на Питоне.
Kotlin logo (previous)
Заметьте, как правило дата классы в Котлине не соответствуют соглашению Java Beans. В бинах подразумевается конструктор без аргументов. А в дата классах наоборот, все свойства принято передавать в конструкторе. В бинах подразумеваются сеттеры и геттеры. В дата классах, если хотите иммутабельность и ставите val, у вас будут только геттеры.
Не все сериализаторы умеют корректно работать с иммутабельными дата классами Котлина. Gson (или его котлиновая обёртка Kotson) — умеет. А вот в Spark соответствующий Encoder ещё не завезли.
Эмулировать Java Beans приходится как-то так:
data class JavaBean(
    var property1: String? = null,
    var property2: String? = null
)

val bean = JavaBean()
bean.property1 = bean.property2
Совсем пустой конструктор в дата классе нельзя. Приходится делать конструктор с дефолтными (нуловыми) значениями для всех свойств. Некрасиво страшно.
Kotlin logo (oldest)
Если посмотреть на котлиновые библиотеки, например, на Mockito-Kotlin, то окажется, что они интенсивно эксплуатируют две возможности Котлина: экстеншен функции и функциональные литералы.
Экстеншен функции — это штука, покраденная, вероятно, из C#. Ну или дальнейшее развитие идеи friend function из C++, если хотите. В общем, это совершенно левые функции, которые, тем не менее, ведут себя как методы объекта (любого нужного типа). Они имеют доступ к target объекту через this. Но они не имеют доступ к приватным свойствам и методам объекта. Технически это просто синтаксический сахар. Но удобный. И приятный.
target.doSomething()
//vs
doSomething(target)
Можно сделать так:
fun Long.asSeconds(): Instant = Instant.ofEpochSecond(this)
fun Long.asMillis(): Instant = Instant.ofEpochMilli(this)

val now = 1511759774L.asSeconds()
val nowMillis = 1511759774000L.asMillis()
«Отсутствующие» явно конструкторы в Котлине поначалу вызывают недоумение. Но потом как-то укладывается. Ведь, в конце концов, чаще всего конструктор используется, чтобы передать и сохранить параметры. А для этого вполне достаточно val/var объявлений в круглых скобках. Если нужно что-то посложнее, есть блок init { }, или даже возможность задать вторичные конструкторы.
Зато из-за такого упрощения конструкторов класс-исключение в Котлине записывается в одну строку.
class MyException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
Кстати, любители сhecked exceptions, ваше время прошло. Как был Ява единственным языком с этой критикуемой концепцией, так им и остался. В Kotlin нет checked exceptions. И нет ключевого слова throws. Впрочем, как вы заметили, checked exceptions нет и в свежих API, добавленных в Яву, например, в java.time.
В Котлине можно писать inline функции. Это позволяет проделывать удивительные, для Явы, фокусы. Например, узнавать тип переменной типа в генериках. Например, вот какая магия запрятана в mockito-kotlin:
inline fun <reified T : Any> mock(
      // тут много опциальных параметров
): T = Mockito.mock(T::class.java, withSettings(
      // сюда эти параметры передаются
))!!
Не пугайтесь !!. Это просто требование получить не null, и выкинуть NullPointerException, если получился всё же null.
Интересно, что тип T известен. Можно получить его класс, и передать в Mockito.
Используется это так:
val mock: MyInterface = mock()
Магия в том, что это inline функция. И reified можно использовать только с inline. Тело функции подставляется в место вызова. И вывод типов Котлина вполне может определить (а в данном примере тип задан весьма явно), что это за T здесь.
Android with Kotlin
Другая очень милая фича Котлина — функциональные литералы. Они сделали их гениально просто и красиво — просто фигурные скобки. Плюс возможность, если последний аргумент функции — функция, указать эти фигурные скобки после круглых скобок (а круглые вообще упустить, если нет других аргументов).
Поэтому можно написать вот такое:
class RunNotTooFrequently(
    val interval: Long
) {

    private var lastRun = 0L

    fun run(block: () -> Unit) {
        val now = System.currentTimeMillis()
        if ((now - lastRun) > interval) {
            lastRun = now
            block()
        }
    }

}

val runner = RunNotTooFrequently(2000)
runner.run {
    // do something
}
В стандартной библиотеке полным-полно таких функций, принимающих функции.
val stream = Files.newInputStream(Paths.get(logFile))

stream.bufferedReader(StandardCharsets.UTF_8).useLines { lines ->
    for (line in lines) {
        // process line
    }
}
В Котлине, в отличие от Явы, где тоже теперь есть всякие лямбды, функции — более полноправные члены языка. Можно просто фигачить функции в .kt файле, снова и питонячьем стиле, и это будет работать. Только будет не очень удобно, потому что все функции тогда будут объявлены в пакете, сам .kt файл никакого неймспейса не создаёт. Соответственно, и при импорте придётся их использовать по их имени. И беда-беда, если имена пересекутся.
А ещё в Котлине есть синтаксис для описания типа функции. Ну там, количество и типы аргументов, и тип возвращаемого значения. Это гораздо удобнее костылей в виде функциональных интерфейсов, что остался в Яве. Но из-за этого бывает, что при вызове какого-нибудь Ява-метода, который ожидает именно что функциональный интерфейс, иногда нужно функциональный литерал Котлина приводить к этому интерфейсу.
Интероперабилити с Явой просто отличный. Не помню серьёзных проблем, чтобы что-нибудь явовое вызвать из Котлина. Наоборот делать не приходилось.
Ну разве что один раз поймал багоособенность с varargs. В Яве что массив указать, что кучу аргументов в vararg метод передать — результат одинаков. В Котлине, если передаёшь массив, нужно не забыть звёздочку перед ним. Иначе передастся именно что один аргумент-массив, что просто невозможно в Яве. Впрочем, там был vararg на Object (each(Object... values)), что лишний раз подтверждает, что типы всё же нужно делать строже.
Kotlin island
Мы юзаем Котлин вместе со Spring и Spring Boot. Оно работает.
Местами получается забавно:
@SpringBootApplication
open class Application

fun main(args: Array<String>) {
    SpringApplication.run(Application::class.java, *args)
}
Главное, не забывать делать классы и методы, которые помечены спринговыми аннотациями, open. Оказывается, современный Спринг очень любит заниматься кодогенерацией, наследовать ваши бедные классы и переопределять методы. По умолчанию в Котлине все классы и методы закрыты от переопределения. Спрингу нужно явно открывать. Есть, конечно, плагин к Gradle, который сделает это неявно. Но лишней магии лучше избегать.
Kotlin vs Java
Люблю Котлин за его лаконичность. Вот сколько строк на Яве займёт вот это?
val intVal = (doc.getValue("number") as? Number)?.toInt() ?: 0
Тут из некоего документа извлекается некое значение (Object, будь он неладен), которое может быть числом, целым или длинным. Нужно получить именно Int. А если не удалось, пусть будет нуль.

2017-11-12

Об ИИ

Что-то в последнее время меня «покусали» роботы. Немножко пополнил своё представление о нашем с вами ближайшем будущем.
За последние месяцы:
Neurons
Джеф Хокинс (не путать со Стивеном Хокингом) — это чувак, который сделал Palm Pilot, придумал рукописный ввод Graffiti, основал Palm Computing и Handspring. Он делал планшеты ещё тогда, когда они назывались наладонниками. Но, если верить книге, он с детства мечтал разобраться в том, как работает человеческий мозг. Поэтому, заработав достаточно денег в IT сфере, в начале двухтысячных он публикует эту книгу и основывает институт по изучению мозга.
Хокинс критикует существующие направления исследований за то, что они не пытаются создать единую теорию функционирования мозга (точнее его интересует в первую очередь неокортекс). Нейрофизиологи могут сказать, какая часть мозга возбуждается, когда мы видим определённые образы, ощущаем определённые эмоции или думаем о чём-то определённом, но не могут сказать, что это означает. Психологи и психиатры могут сказать, как определённые переживания в прошлом могут сказаться на нашем поведении в будущем, и как избавиться от ненужных переживаний, но не имеют понятия о том, какие физические процессы при этом происходят в мозгу.
Хокинс предлагает объединить усилия и излагает свою теорию функционирования неокортекса. Делает это он из айтишных побуждений, чтобы создать действительно мыслящие машины. Он и направления искусственного интеллекта в связи с этим критикует.
Дело в том, что даже нейронные сети, которые вроде как повторяют структуру мозга, работают не так, как мозг. Нейронные сети сначала обучаются, а потом работают. А мозг постоянно обрабатывает сигналы, и постоянно обучается. Это непрерывный процесс.
По Хокинсу неокортекс — это иерархическая машина узнавания паттернов во входных сигналах и прогнозирования (этих сигналов). Входные сигналы — это сигналы от органов чувств. Ответная реакция мозга — это в первую очередь движения, причём движения глаз, включая саккады, и движения языка и гортани при речи — это всё тоже ответы мозга.
Интересна иерархия. На нижних уровнях распознаются простые вещи: для зрения — линии разных направлений, для слуха — тона звуков. Несколькими уровнями выше распознаются уже целые образы: лица, мелодии. Ещё выше распознаются абстрактные понятия вроде людей, кошек, собак вообще. Где-то на этом уровне появляется то, что мы воспринимаем как наше создание, то есть умение оперировать абстракциями.
Все эти уровни работают одинаково, узнают и предсказывают. Только чем выше по иерархии, тем более абстрактны понятия, которые они узнают и предсказывают. И чем выше по иерархии, тем больше мозготоплива (буквально) требуется для принятия решения и реакции на окружающие взаимодействия, потому что буквально задействовано больше нейронов. Слоёв этих, похоже, что около сотни (исходя из типичного времени реации и типичной скорости распространения нервных импульсов).
Обучение как раз и заключается в том, чтобы перенести возможность принятия решения на более низкие уровни. Довести до автоматизма, выработать привычку. На низком уровне решение принимается быстрее и дешевле. Но работает оно только на заученных паттернах. Если встречается что-то незаученное, приходится задействовать более высокие уровни иерархии, «включать сознание».
Самые нижние слои имеются у всех млекопитающих. Где-то там мы подобны мышкам и ёжикам. У обезьян слоёв побольше. Но человек может оперировать более абстракными понятиями и делать более долгосрочные прогнозы, чем обезьяна. Наверху есть вершины осознания, доступные только человеку.
Что касается достижений концепции Хокинса в сфере искуственного интеллекта, то их наработки вполне доступны. Смотрите numenta.com.
Бодрствование
Иван Пигарёв — физиолог. Занимается исследованием сна. Развивает висцеральную теорию сна.
Ставили опыты. Не давали мышкам спать. И мышки умирали через несколько дней. Умирали от множественных повреждений внутренних органов. А мозги были целёхоньки. То есть сон нужен не мозгу, а внутренним органам.
Терия в том, что во время сна мозг (кора мозга) отключается от сигналов внешней среды, а подключается к сигналам от внутренных органов. Этих сигналов весьма и весьма много. И наши суперумные мозги занимаются их обработкой. Хотя сознание тут не задействовано. И без этой обработки и регулировки, на автономном обеспечении, внутренние органы просуществуют недолго.
Выглядит очень и очень странно. Каким образом те нейроны, которые в бодрствующем состоянии натренированы на распознование зрительных образов, во сне обрабатывают сигналы от кишечника? Впрочем, тут подсказывают, что свёрточные нейронные сети, натренированные на распознавании изображений, можно успешно использовать и в других областях, если им отрезать и заменить входные и выходные слои. Возможно, что-то подобное происходит и в мозге. Меняем внешние слои обработки сигналов и выходных воздействий со слоёв приёма и выдачи внешних сигналов на слои приёма и выдачи внутренних сигналов, и вовсю задействуем крутую натренированную нейросеть глубинных слоёв.
Кстати, сон — это не привилегия высших животных. Мухи тоже спят. Похоже, это забавный эволюционный механизм. Если уж у нас появились такие сложные внутренности, почему бы не воспользоваться нашей крутой центральной нервной системой, чтобы порулить внутренностями? Во сне.
Но куда опять потерялось сознание? В базальных ганглиях, которые неактивны во сне? Впрочем, боюсь, что в структуре мозга может быть много нелогичного. И сознание может не быть где-то конкретно. Не инженер же проектировал. Но, по Хокинсу, сознание должно быть где-то в самой глубине свёрточной сети, оперируя максимальными абстракциями. Как можно отключить внутренности свёрточной сети? Или сознание — это действительно что-то особенное и сбокут.е.?
Персональный вывод: надо спать. Сон важнее, чем первая пара. Хочется спать — надо спать. А то всякие язвы желудка и всё такое...
А роботам спать не надо. У них же нет внутренних органов. Ну или для самодиагностики можно выделить дополнительную нейросеть, попроще. А вот где сознание? Неужели, если мы разберёмся в работе неокортекса и скопируем его, то получим умные, но совершенно бессознательные машины? Может, это и хорошо?
Взрывной рост ИИ
Тим Урбан — один из основателей сайта Wait But Why. Оттуда, и с разрешения Тима, Макс Дорофеев взял обезьяну и рационального типа, сосуществующих у нас в голове, для своих джедайских техник.
Про ИИ Тим Урбан пишет подробно. Рассматривает оптимистичные прогнозы Рэймонда Курцвейла. И опасения Илона Маска и компании, в лице Института будущего человечества, по поводу сверхинтеллекта.
Никто не сомневается, что рано или поздно, так или иначе, интеллект, подобный человеческому, и даже значительно его превосходящий, будет создан. Вопрос: чем (или кем) будет этот сверхинтеллект? И какое отношение у него будет к человеку?
Курцвейл чрезвычайно оптимистичен. Он считает, что человечество и созданный им сверхинтеллект — это будет одно и то же (привет, киборги!). А значит, всем будет хорошо.
И понятно, чем обеспокоены Маск и Гейтс. Искусственный сверхинтеллект не будет человеческим. И не будет животным. Ему будут совершенно неведомы эволюционные мотивы выживания и сосуществования. Он не будет проходить путь многолетнего воспитания. И он будет весьма могущественным. Настолько могущественным, что человек сдержать его не сможет. И как он отнесётся к человеку? Трансгуманизм по всей красе.
Азимову было хорошо. Три закона роботехники являлись неотъемлемой частью позитронного мозга. Мозга человеческого раба. Были его моралью. Но как привить мораль искусственному интеллекту, который мы сейчас можем построить, никто не знает. Да что там, мы не знаем даже, что такое человеческая мораль. Да и не факт, что сверхинтеллект не сможет обойти собственные моральные ограничения.
Her
В этом смысле фильм «Она» («Her», «Её»?) очень оптимистичен. ИИ родился, научился у человека любить, развился и умотал куда-то, оставив человечество в полнейшем недоумении. Ну прям как «Волны гасят ветер» у Стругацких. Но будут ли у настоящего сверхинтеллекта столь тёплые чувства по отношению к человечеству? Отсутствие ответа на этот вопрос как раз и пугает.
Впрочем, ныняшняя «умная операционка», в лице Алисы от Яндекса, хоть и весьма неплохо умеет гонять речь в текст и текст в речь, весьма тупа. И всякие домашние роботы, в большинстве своём игрушки, от Aibo до Cozmo, и даже вполне серьёзные роботы-ассистенты, тоже тупые. Ну не производят они впечатление умного (или хотя бы живого) существа. Даже до котёнка нормального не дотягивают.
Так что, надеюсь, ещё лет десять-пятнадцать у нас ещё есть. У человеков. Ещё лет десять-пятнадцать не бояться того, что появится кто-то умнее нас. А за это время, глядишь, и свыкнемся с этой мыслью. Или сами себя уничтожим, вместе с ростками потенциального сверхинтеллекта. Уничтожим по глупости, конечно. Ибо отказываться от прогресса вообще, и сверхинтеллекта в частности, никто даже не собирается. К Батлерианскому джихаду мы пока явно не готовы.

2017-10-30

Об Apache Spark

А вот вам заметки полного нуба об Apache Spark.
Apache Spark Logo
Именно Apache Spark™. А то есть ещё какой-то веб-фреймворк Spark. Не говоря уже о Twilight Sparkle. Плохое название. Гуглите осторожнее.
Apache Spark — это такая штука для распределённых вычислений. Эту всякую бигдату обрабатывать. Причём это не платформа для распределённых вычислений, типа Hadoop, а скорее фреймворк для распределённых вычислений. На нём можно писать эти распределённые вычисления. На Scala, Java, Python или R. Примерно однотипно на всех языках. А выполнять эти распределённые вычисления уже на кластере в Hadoop (точнее YARN) или в Apache Mesos. (Господи, всюду Апач.)
Чертовски привлекательно, что можно обойтись и без кластера, а запустить Спарк в локальной JVM. При этом, если у вас достаточно мощная машинка, и HDFS вы всё равно не используете, а значит, локальность данных вас особо не волнует, даже в таком локальном запуске можно сожрать изрядно оперативы и ядер ЦПУ, и перемолоть изрядную кучку данных.
Как-то так:
$ $SPARK_HOME/bin/spark-submit \
    --class "my.runners.App" \
    --master local[4] \
    target/scala-2.11/my-assembly-0.1.jar \
    --data-mongo-uri mongodb://... \
    --target-mongo-uri mongodb://...
Вот так локально мы и перемалываем несколько десятков гигабайт недельных данных за пару десятков минут, чтобы найти некоторые аномалии...
R я не знаю. Python в Spark не пробовал. А Spark изначально запилен на Scala. Пришлось немного освоить Scala.
Согласно последней политике продвижения Scala, все Scala продукты должны иметь хороший Java API. И у Спарка он есть. Так и написано в жалком подобии явадоков: вот этот метод для Скалы, а вот этот для Явы.
На Яве получается заметно многословнее, чем на Скале. Нет имплиситов, поэтому тот же Encoder везде надо явно втыкать. Encoder — это такая штука, которая описывает "схему" обрабатываемых данных. Данные же нужно гонять между узлами кластера, и для этого надо понимать, какой они "формы". Примитивные типы, case классы Scala, некоторые коллекции поддерживаются из коробки. Для Java ещё java beans тоже энкодятся. Как правило, этого достаточно.
Но я ведь попробовал пописать на Kotlin. Осторожно, spark-kotlin — это для веб-фреймворка, а не для Apache Spark. И на Котлине мне не понравилось. Уж лучше слегка научиться Скале.
Имплиситов в Котлине нет. Энкодеры надо прописывать явно. Data классы Котлина поддерживаются при этом не в Kotlin-way.
Вот такой data class работать не будет:
data class KotlinDataClass(
    val a: String
)
Потому что это не java bean. Тут конструктор не умолчательный. И сеттеров нет.
Нужно сделать java bean:
data class JavaBean(
    var a: String? = null
)
Вот теперь будет работать в Спарке. Хотя выглядит чудовищно с точки зрения Котлина.
Пользоваться приходится API для Java, JavaSparkContext и JavaRDD. Потому что скаловых коллекций Котлин не умеет (слава богу).
Бывает, что Котлин видит пару методов с разными сигнатурами. Один принимает скаловую функцию. Другой принимает какой-нибудь org.apache.spark.api.java.function.MapFunction. Второй метод явно добавили для Java API. Но Котлин эти сигнатуры не различает. Приходится явно функциональный литерал Котлина приводить к типу Function. Уродливо.
import org.apache.spark.api.java.function.MapFunction

//...

return df.map(
    MapFunction { o: MyObject ->
        //...
    },
    MySchema.myEncoder
)

//...
Через пару дней мучений, я всё же решил: Скала, так Скала.
Spark Bricks
Всё в Спарке крутится вокруг трёх штук, которые являются развитием одной и той же идеи. Это RDD (Resilient Distributed Datasets), DataFrame и Dataset. Их можно прочитать, из файлов (в том числе и HDFS) или из БД. Их можно записать, в файлы (в том числе и HDFS) или в БД. Над ними можно делать операции. Примерно те же операции, что и над Stream, что в Java 8: map(), flatMap(), filter(), reduce(), groupBy(), вот это всё. Только вот весь бигдатный набор данных будет побит на партиции, и каждая партиция будет обработана на воркере в кластере, а результат будет собран в кучку на том узле, который инициировал вычисления и называется Driver.
Разница между RDD, DataFrame и Dataset в типизации. RDD исторически был первым API Спарка. И он умеет работать только с кортежами (которые tuple). Т.е. все вот эти мапы и прочее вы будете делать с кортежами.
Dataset (и DataFrame) — это более новый API Спарка. В DataFrame вы имеете дело со схемой данных. Известны имена и типы столбцов. Соответственно, по именам можно стобцы извлекать, удалять из набора данных, и всё такое. Можно об этом думать как об очень длинной SQL таблице. Даже некоторые операции можно выражать на подмножестве SQL.
В Dataset вы имеете дело с объектами. Типа ORM. Очень удобно для таких объектов использовать case классы Скалы. На самом деле, технически DataFrame не существует, это Dataset[Row]. Но в работе гораздо удобнее более типизированный Dataset[MyCaseClass]. Вы задаёте класс при загрузке данных, и сразу получаете датасет нужных объектов. По ходу манипуляций у вас получатся другие объекты, и их снова можно сохранить в какую-нибудь коллекцию какой-нибудь БД.
Схема (т.е. набор и типы колонок) вполне успешно самостоятельно выводится из набора полей класса. Но есть возможность указать её самостоятельно. Это полезно, если у вас в какой-нибудь Mongo коллекции завалялись документы разных форм. Тогда указание схемы позволит выбрать только документы нужной формы, пригодные для обработки.
Читать/загружать наши датасеты можно из файлов, из любой реляционной БД через JDBC, из MongoDB. Если читаем из HDFS, вовсю работает локальность данных. Т.е. воркерам достанется на обработку та партиция, которая расположена на том же узле. Если читаем из реляционной БД, на локальность данных всем насрать, как я понимаю. Если читаем из MongoDB, может учитываться расположение шардов, если они у вас есть. Разбиение на партиции — это обязанность коннектора к БД. Для Монги есть свой коннектор, который понимает шарды. Для JDBC есть один общий коннектор, который может работать с любыми JDBC драйверами.
Это бигдата. Читать в датасет вам придётся всю таблицу или коллекцию. Целиком. Впрочем, в MongoDB есть возможность задать шаг aggregate, который будет вставлен в самом начале. Очень имеет смысл добавить туда $match, который выберет только то, что нужно.
Коннектор к MongoDB читает очень странно. На мой взгляд. Сначала делается большой aggregate(), в котором выполняется $sample, выбираются только _id порядка 10% всех документов. А потом уже выбираются все документы, с разбиением по диапазонам (от и до) _id. Вообще-то _id не образуют континуум, совпадающий с запрошенными данными, хоть они и упорядочены. В результате, как минимум, некоторые документы бывают пропущены, ибо их _id не попали в первоначальный $sample. Может в Big Data так принято, но мне такой алгоритм кажется странным.
В бигдате много странного и непривычного. Спарк не умеет обновлять данные. Он легко может создать новую таблицу или коллекцию и выгрузить туда весь датасет, который получился в результате вычислений. Но дальше уже ваша забота, куда эту таблицу засунуть. Как правило, её нужно как-то смержить с имеющимися данными. Например, как-то так:
INSERT IGNORE INTO existing_data (...)
SELECT ... FROM spark_result;
В случае с MongoDB можно попробовать сделать апдейт и в Spark. Опять таки, всё зависит от коннектора. Коннектор MongoDB умеет обращать внимание на _id документа. И если в датасете есть _id, будет сделан upsert, а не insert. Таким образом, можно загрузить датасет оригинальной коллекции и сджойнить с результатами обработки, обновить объекты. А потом обновлённый результат выгрузить в оригинальную коллекцию, обновить документы. Это всё работает, но только если вы вызовете правильный метод MongoSpark.save[D](dataset: Dataset[D], writeConfig: WriteConfig). Другие методы сохранения в MongoDB плевать хотели на _id.
Ну и лучше так не делать. Во-первых, вам придётся грузить в Спарк на одну коллекцию больше. Во-вторых, joinWith() — не самая лёгкая операция для Спарка. В-третьих, вы полностью перепишете оригинальную коллекцию, и все изменения, внесённые в неё третьей стороной с момента загрузки в Спарк, до момента выгрузки из Спарка, будут потеряны. Проще выгрузить из Спарка результат вычислений в отдельную коллекцию, а потом смержить уже средствами Монги. Это небыстро, но надёжно.
db.spark_result.find().forEach(
    function(doc) { db.existing_data.update(
        { ...update key... },
        { $set: { ...update operation... } },
        { upsert: true, multi: true })
    })
Apache Spark — типичный инструмент Big Data. Всё что вы можете: загрузить громадный объем данных, обработать его по кусочками, в параллель на узлах кластера, и выгрузить результат снова одним большим куском данных. Никакой модификации имеющихся данных, только создание новых. И при этом ещё могут быть погрешности в чтении :)
А иногда надо разветвиться. Подсчитать на исходных данных какую-нибудь тяжёлую статистику. А потом, по этой статистике, родить несколько разных результатов. Теоретически, для каждого результата нужен свой конвейер. Но тогда придётся для каждого конвейера заново вычитывать исходные данные и считать статистику. Скучно и неоптимально.
Поэтому в Спарке есть кэш. Метод persist(). Любой шаг конвейера можно сохранить в кэш. И начать новый конвейер с этого кэша. Будет быстро. Главное, чтобы памяти хватило. А не хватит памяти, можно использовать диск. На воркерах. Будет медленее, но это всё равно быстрее, чем вычитывать всё заново из внешней БД. Тем более, что Спарк хранит в кэше объекты, сериализованные с помощью Kryo. Главное, всё правильно подтюнить.
Похоже, в тех случаях, когда всё равно нужно перелопатить добрые сотни миллионов записей, чтобы получить какие-то результаты, Apache Spark является отличной альтернативой попытке перелопатить это средствами самой БД. Выйдет быстрее (потому что обработка будет делаться на других узлах, а не в БД) и значительно гибче (потому что это всё же код на Scala, где можно делать почти всё, что угодно). Вот только хочется нормального API и для Kotlin :)
Twilight Sparkle

2017-10-15

Об играх

В игры я почти не играю. Раньше играл. А сейчас почти не играю.
Нет, я, конечно, снова прошёл весь Carmageddon, когда он вышел под Android. И с удовольствием резался в Plague Inc.. И даже где-то у меня есть аккаунт в Steam, где пылится честно купленная на распродаже за нуль рублей Portal.
Carmageddon
Но я сейчас о другом. Я, оказывается, ни разу не задумывался, как делаются современные игры. А это прям отдельная Вселенная.
В студенческие годы, я, конечно, писал игрушки. Под DOS, на C и C++. Развлекался с трёхмерной графикой, освещением и текстурами в режиме 320х200 c 256 цветами. Трёхмерные крестики-нолики делал. А ещё в текстовом режиме были «Быки и коровы» и «Жизнь».
Чуть позже я немного развлекался с Borland C++ Builder (именно ещё от Borland). И наклепал для Windows тетрис (любой уважающий себя программист должен написать тетрис), решалку японских кроссвордов и даже симулятор MK-61 (ну как бы уже не совсем игра).
С тех пор игр не писал. А зачем? Какой от них толк? Интереснее сделать что-нибудь полезное. Для себя.
Ludum Dare
Но случайно таки оказался недавно на омском Ludum Dare. И там я узнал, что игры пишутся совсем не так, как пишутся обычные сетевые приложения, или как обычные гуёвые приложения.
Как работает обычный сервер? Он ждёт запросов. Пока запросов нет, он ничего не делает. Когда приходит запрос, он его обрабатывает. Запускает ли он для этого другой процесс, или другой поток, или всё делает в общем (для кучи соединений) потоке-воркере (то, что называется асинхронным) — не важно. Важно, что всегда есть запрос-ответ. Нет запроса — ничего не происходит (как правило).
Запросы создаёт клиент. Именно он является инициатором всего этого безобразия. Нет клиентов — нет запросов — ничего не происходит.
Аналогично в GUI. У нас есть события: движения мышки, нажатия кнопок и клавиш. Когда событие происходит, оно должно быть обработано. Графическим элементом, над которым оно произошло, окном и т.д. И тут, кстати, не придумали особо изящного способа обработки всех этих событий, кроме как одного, в одном потоке, бесконечного (но блокирующегося) цикла. Да, это тоже асинхронщина.
Пока вы двигаете мышкой из одного угла экрана в другой, происходят тысячи событий, в десятках разных приложений. Но если вы взялись ковырять в носу, и убрали руки с мыши и клавиатуры, то событий не будет. И снова ничего не будет происходить. Ну разве что курсор будет мигать. На старых терминалах мигание вообще делалось аппаратно.
Общее тут то, что пока нет события или запроса, ничего не делается. А когда события и запросы пошли, их все нужно успеть обработать. В современных GUI это не всегда тривиально. Но оно так и работает.
Java GUI Event Loop
В играх всё по-другому.
Даже роли клиента и сервера отличаются. Да, клиент по-прежнему подключается к серверу. Но дальше идут не запрос-ответ, а отправка сообщений. В обе стороны. Клиент сообщает о действиях игрока. Сервер сообщает о состоянии игры (изменённое действиями и этого, и других игроков). Существенное правило безопасности: сервер знает всё, а вот клиент должен знать только то, что ему положено знать. Нарушение этого правила порождает интересные возможности для тех, кого называют читерами (к примеру, в World of Tanks это вообще весело).
В играх игрок тоже жмёт кнопки и елозит мышкой. И это тоже может порождать события. Но эти события никого особо не интересуют. События не порождают немедленных действий по их обработке. Да, координата мышки, или номер нажатой кнопки могут быть где-то запомнены, чтобы знать, что кнопка нажималась. Но и всё.
Потому что в играх — постоянный polling. В бесконечных циклах. С задежкой. Это, конечно, не обязательно должны быть while (true) в отдельных потоках со sleep() внутри. Можно всё сделать хитрее, на таймерах и всё такое. Но сути дела это не меняет.
Есть цикл для отрисовки. Который даёт те самые fps. Тут всё просто. Берётся состояние игры, с точки зрения игрока. И отрисовывается. Столько раз в секунду, сколько надо. Само состояние может меняться реже или чаще.
Есть цикл опроса действий игрока. Тут как раз мы выясняем, какие кнопочки нажаты, где находится мышка. И, либо прямо модифицируем состояние игры, в случае сингл плеера. Либо передаём намерения игрока на сервер, в случае сетевого мультиплеера.
На сервере намерения игроков накапливаются. А потом происходит тик игры. Это самый главный цикл, где изменяется глобальное состояние игры. Раз и навсегда. Тут просчитываются всякие перемещения, уроны, жизни и т.п. И тут очень важным параметром является время, прошедшее с предыдущего тика. Как правило это время не может быть в точности постоянным. И это надо учитывать, дабы персонажи, как минимум, перемещались равномерно. И изменённое состояние игры рассылается всем игрокам.
Из-за ограничений в скорости сети, эти рассылки идут значительно реже, чем fps. Что порождает некоторые особенности. Все эти опросы, просчёты, отрисовки происходят каждый в своём цикле. И они в общем случае не синхронизированы. Нужны какие-то правила синхронизации.
Например, что делать, если игрок упорно движется в определённом направлении, а с сервера обновлённого состояния, где произошло перемещение в данном направлении, ещё не пришло? Клиент может взять на себя смелость и изменить местоположение игрока, чтобы при прорисовке перемещение происходило плавно. Это называется интерполяцией. Если прогнозы клиента, и обновлённое состояние с сервера совпали — всё хорошо. А вот если выяснится, что игрока по пути кто-то убил, придётся как-то выкручиваться и дорисовывать, что сначала мы дошли туда, а потом, метром ранее, умерли.
Пересылка сообщений. Циклы опроса. Асинхронная пересылка сообщений. Синхронизация состояний. Безопасность и доверие. Вот это вот всё — игры.
Может, я рассматриваю лишь частный случай игродельной индустрии. Но мы на Людуме клепали так.
Game Loop
Почему же классические клиент-серверные архитектуры, а так же GUI, построены по-другому? Дело в экономии ресурсов? Выгодно ничего не делать, пока нет событий? В играх ведь все циклы: отрисовки, опроса, просчёта следующего состояния игры — колбасят всегда, независимо от действия или бездействия игрока. CPU и сеть никто особо не жалеет.
С другой стороны, где-то циклы с постоянными опросами всё же встречаются? Уж не в ядрах ли ОС? Тот самый поллинг сетевой карты вместо прерываний. Нет? А в RTOS, случайно, не осуществляется такой же тотальный контроль над временем выполнения задач? Ну не успел выстрел игрока попасть в вычисления данного состояния игры, значит выстрела на данном шаге не будет.
Какая-то другая Вселенная — эти игры. Где бы это применить, не начиная писать игры? :)