[recovery mode] Kotlin/Golang работа в двух языках

35ae2cd4bce406518f858f03ab3a3725

Сразу дисклеймер, статья больше про Golang, но мой «родной» и основной на протяжении уже 6 лет — Kotlin — буду рад если будут замечания по Golang части в комментариях

Немного о себе — системный архитектор компании SpectrumData, тут вроде как по канонам хабра ни рекламы ничего давать нельзя, но есть канал по программированию у нас — можете найти — может что есть интересного. В архитекторах я оказался из разработчиков, стаж более 20 лет на разных платформах и задачах. Сейчас тоже стараюсь и сам писать и есть команды разработчиков в подчинении.

Никогда не писал на хабре. Обычно если какой-то материал появлялся, то для внутренних нужд или лучше смотрится в ролике. Но тут материала подкопилось текстового. Решил написать статью. Может кому-то будет интересно.

Так уж получилось, что у нас в компании используются разные стеки и языки. И в частности у нас есть большое подразделение, основным стеком которого является JVM с Kotlin в качестве языка разработки (вместо ванильной Java, на бэкенде). Но при этом этому же отделу регулярно приходится использовать в работе GoLang. В частности бывают кейсы:

  • портирования кода (в обе стороны)

  • реализации каких-то компонентов сразу на 2-х языках (в основном это внутренние SDK)

Сразу скажу — почему эта задача вообще в целом для нас легкая и подъемная — наши бэкенды на Kotlin строятся на микрофреймворках типа Ktor, а не на Spring или не дай бог JavaEE, соответственно тяжелых вопросов соответствия каких-то лютых Enterprise монструозных JAR каким-то решениям в Golang не стоит.

Естественно, что мы сейчас говорим про языки и соответствие КОНСТРУКЦИЙ , а не про библиотеки или фреймворки.

Ну и некоторых ставит поначалу в ступор, что Kotlin/JVM это «про классы» и «не натив», а Golang это вроде как «процедурный стиль» и «натив».

На деле практически все довольно органично воспроизводится. В этой статье приведу некоторые примеры взаимозаменяемых конструкций и хаков. Материал в основном для тех кто пишет на Kotlin и для кого Golang — второй язык.

Сразу оговорюсь, что при переносе можно выделить несколько ситуаций, под одну гребенку все невозможно завести.

Простые:

  1. перенос один в один или очень близко в той же структуре кода (без учета непосредственно синтаксиса языка) с сохранением канона (каноничного стиля кода GoLang) — да почти все

  2. перенос один в один или через хаки, но без сохранения канона — код и API в итоге очень похоже на Kotlin, но при этом код не каноничен для GoLang — например статические методы, синглтоны, компаньоны

  3. нельзя перенести один в один, но есть канонические легко осваиваемые паттерны которые «по духу» и смыслу аналогичны Kotlin — как ни странно — почти все ООП воспроизводится без особых потерь на структурах без классов

Тяжелые:

  1. требуют пересмотра парадигмы языка и переноса не в лоб, требуют хорошего понимания концепций обоих языков — например соответствие пакетов в JVM и распределения кода и пакетов в Golang, любые переносы решений с большим использование корутин (они обманчиво похожи на горутины, но требуют иного планирования)

  2. можно перенести только хаками, при этом код на выходе плохой, не каноничный и при этом не до конца полный по смыслу и духу — это попытки полностью реализовать перечисления как классы, тотальную иммутабельность и защиту от NPE, решения сильно завязанные на рефлексию и т.п.

  3. вообще нельзя сделать идентичным — не так много таких вещей, но по факту это все, что сильно завязано на генериках в JVM понимании и сахарная функциональщина типа DSL

List.filter vs List.map

Начнем с примера в котором сразу будут показаны некоторые типовые переносы и один невозможный перенос.

Итак мы хотим перенести в Go функциональную обработку коллекций (а там этого явно не хватает, понятно есть какие-то внешние пакеты, но допустим хотим свое)

fun  List.filter(condition : (T)->Boolean): List {
    return buildList {
      for (item in this) {
        if (condition(item)){
          add(item)
        }
      }
    }
}
fun  List.map(mapper : (T)->R): List {
    return buildList {
      for (item in this) {
          add(mapper(item))
      }
    }
}

И вот мы начинаем воспроизводить

Во-первых мы хотим это исполнить именно как метод, а не как функцию, чтобы их делать в цепь l.Filter().Filter().Map().First(), а не вкладывать First( Map( Filter ( Filter(l)))

Пробуем решить в лоб (не получится)

// пробуем навесить метод прямо на срез
func (s []any) Filter(condition func(item any) bool) []any

Сразу куча проблем — во-первых так нельзя — навешивать функции на чужие типы, во-вторых у нас резко теряется информация о типе!

// пробуем сделать generic-метод
func (s []T) Filter[T any](condition func(item T) bool) []any

а так тем более нельзя — потому что вообще нет GENERIC методов в Golang, не завезли, функции есть, а методов — нет!

Но тут на помощь приходит то, что по своей природе Golang — это в своей основе C, где нет аьясов типа, а есть создание типа на основе данного. Вот так можно:

type List[T any] []T
func (l List[T]) Filter(condition func(item T) bool) List[T]

Итак — первое, что уже можно выучить — нельзя навесить «расширение» на уже кем-то в другом пакете написанную структуру, но можно сделать тип в своем пакете, эквивалентный целевому и сделать метод уже у него!

но так просто это использовать не получится, потребуется:

// так не получится ([]int{1,2,3}).Filter(func(item int) bool {return item > 1})
// а вот так да:
List[int]([]int{1,2,3}).Filter(func(item int) bool {return item > 1})

не красивый повтор параметра типа…, немного усовершенствуем:

// сделали а-ля приватную структуру, которую снаружи в явном виде создать нелья
// но в отличие от Kotlin можно ВОЗВРАЩАТЬ
type _ListType[T any] []T
// навесили на нее наш метод
func (l _ListType[T]) Filter(condition func(item T) bool) _ListType[T] {...}
// сделали "конструктор"
func List[T](l []T) _ListType[T] { return _ListType[T](l) }

// теперь сработает автовывод типа
mylist := List([]int{1,2,3}).Filter(...) // _ListType[int]

Заодно приведем вариант реализации этого Filter, вдруг она кому-то не очевидна

func (l _ListType[T]) Filter(condition func(item T) bool) _ListType[T] {
    var result []T
    for _, item := range l { // _ListType[T] все еще []T
        if condition(item) {
            result = append(result, item)
        }
    }
    return result  // автоматический апкаст до _ListType[T] автоматически
}

Окрыленные своим успехом, мы без проблем реализуем такие методы как Take, TakeLast, Drop, DropLast, First, FirstOrDefault…

Кстати, а как сделать FirstOrDefault()?

И тут как это ни странно в Java/Kotlin, при всем богатстве рефлексии — это сложно, так как не очень понятно как именно в общем случае (не в частном, а общем) получить дефолтный экземпляр некоего типа T!!! Вот, что примерно бы было в Kotlin:

fun  List.firstOrDefault(): T {
    if (this.size > 0) return this[0] // тут все просто, а вот дальше...
    // все, приплыли
}
// немного переделаем
fun  List.firstOrDefault(clazz : KClass ): T {
    if (this.size > 0) return this[0] // тут все просто, а вот дальше...
    return clazz.createInstance() 
    // ну и мы понимаем, что это ни разу не общее решение и с кучей типов 
    // это не сработает как надо !!!
}
// добавим сахара
inline fun  List.firstOrDefault(): T = 
    this.firstOrDefault(T::class)

В Golang это решается проще и можно запомнить идиому:

func (l _ListType[T]) FirstOrDefault() T {
    if len(l) > 0 { return l[0] }
    var def T // просто определяем переменную! 
    // и так как в GO все переменные инициализируются дефолтным значением,
    // например 0, "", nil, пустая структура - то все, вуаля - можно возвращать
    return def
}

Зато сложнее получить в общем случае поведение DefaultOrNil (), которое в Kotlin несколько проще достигается… ну это уже совсем нюансы

Итак — второй «хак» — в Golang легко получить дефолт любого типа ,

просто определив переменную этого типа

И вот мы очень все еще окрылены нашим успехом переноса функциональщины, частично генериков и расширений и все идет как надо…

Более того все переносы они даже и канонов каких-то особых не нарушают и читаются легко.

Но тут мы резко и без предупреждения споткнемся о такой простой метод как List.map, напомню его код:

fun  List.map(mapper : (T)->R): List {
    return buildList {
      for (item in this) {
          add(mapper(item))
      }
    }
}

Пытаемся в лоб:

func (l _ListType[T]) Map[R any] (mapper func(src T) R) _ListType[R] {
    var result []R
    for _, item := range l {
      result = append(result, mapper(item))
    }
    return result
}

И тут мы упремся в короткое и лаконичное сообщение компилятора Golang:

syntax error: method must have no type parameters

О как! Обычные функции могут иметь тип-параметры , а методы (у которых есть ресивер) — нет! И более того нет никаких признаков, что их в ближайшее время завезут (!!!).

И вот тут мы напарываемся на первую преграду действительно серьезную:

Шаблоны (генерики) в Golang намного слабее и не идут ни в какое сравнение по мощности и выразительности ни с Java/Kotlin ни тем более с Rust или с теми же шаблонами C++. Если ваше решение сильно завязано на генерики и они есть как у классов, так и у методов или расширений — скорее всего это та грань и та черта проекта, которая будет практически невозможно перенести на Golang без потерь в эргономике или семантике!!!

И получается, что в рамках нашей задумки вполне можно реализовать методы, которые не требуют второго генерика и не получится нормально тех, которые требуют (Map, Zip, частично Fold, Reduce).

Соответственно мы можем реализовать Map, Fold, Reduce только в варианте с тем же типом, но не в обобщенной форме, то есть на вход List и на выход List или T, но не List, R:

func (l _ListType[T]) Map (mapper func(item T) T) _ListType[T] {
    var result []T
    for _, item := range l {
        result = append(result, mapper(item))
    }
    return result
}

В таком виде естественно будет работать -, но очевидно что это не тот Map о котором мы джва года уже мечтали…

Соответственно какие выводы можно сделать:

  • в целом нет сложности в переносе «функциональщины», «ламбд» и расширений, со своими нюансами, плюсами и минусами -, но примерно понятно и комфортно

  • но если решение все построено на шаблонах, на косвенной типизации — то в Golang надо будет переработать саму модель использования этого кода или пойти на уйму компромиссов

И кстати частный случай компромисса, достаточно простой, в Kotlin мы имеем перегрузку методов по сигнатуре на одно и то же имя (как это было еще заведено в Pascal):

fun myFun(s: String) {...}
fun myFun(i: Int) {...}
...

В golang так нельзя, потребуется

func MyFunS(s string) {}
func MyFunI(i int) {}

как это было бы еще в C, но при этом конечно имена функций будут разными и содержать тип параметра в том или ином виде

или же можно воспользоваться тем, что в Golang можно использовать несколько иную модель ограничений генериков, и если у нас фиксированный список поддерживаемых перегрузок (замкнутый), то можно:

func MyFun[T string|int] MyFun() {}

но тогда внутри придется делать switch по типу, в общем не факт, что это так уж хорошо

Свойства, конструкторы, инициализация

Тут на самом деле все переносится более менее легко, как ни странно.

Возьмем какой-то такой код на Kotlin, несколько синтетический, но полный всяких фич, которые кажутся не поддерживаемыми на Golang

// интерфейс со свойством, в go нет свойств
interface IMyInterface { 
  val x : Int 
  // в go нет никаких компаньонов или статических методов 
  // для интерфейсов, вообще статических нет методов
  companion object {
    fun createDefault() : IMyInterface = MyDefaultImpl()
  }
} 
// явная типизация интерфейсом, а не утиная, в go нельзя явно указать
// что структура держит интерфейс!

// у параметров не бывает дефолтов
private class MyDefaultImpl (i: Int = 10) : IMyInterface { 
  // в структуре нельзя прямо прописать связанность полей
  private val d: Double = i.toDouble()
  // нет аналога init в golang
  init {
    require(d > -1.0) {"d должно быть больше -1.0"}
  }
  // ну какие в golang свойства и lazy
  override val x by lazy { return (d * 2.13).toInt() }
}

Как ни странно — практически все из этого на Golang выполнимо практически без пересмотра семантики и даже без особых изменений в API

package my
// или GetX() - ну да, свойств нет, но getter никто не отменял!
type IMyInterface interface {
  X() int 
  // тему "статических" методов и компаньонов оставим на конец
}
// все что не на большие буквы - private, точнее package private
type _MyDefaultImpl struct {
  d float64 // поле которое при создании заполняется
  x int     // кэшированный lazy результат
  lazy_x bool // признак, что lazy уже вызывался
}

// определим дефолт, его будем потом уже при вызове использовать
const _DEFAULT_I = 10
// приватный конструктор ну и раз в kotlin по ссылке все,
// то и тут вренем по ссылке
func newMyDefaultImpl(i int) *_MyDefaultImpl {
  // и сейчас некоторый аналог init
  d:=float64(i)
  // некоторое воспроизведение require
  if d < 1.0 {
    panic("d должно быть больше -1.0")
  }
  // собственно вернули структуру
  return &_MyDefaultImpl{d: d}
}
// ну и реализуем интерфейс
func (d *_MyDefaultImpl) X() int {
  // собственно lazy getter и можно в принципе и по синхронизации
  // порешать через mutex, в данном примере особой нужды делать это не видел
  if !d.lazy_x {
    d.x = int(d * 2.13)
    d.lazy_x = true
  }
  return d.x
}


// тему статического факторизующего метода, можно сделать канонично для GO
func IMyInterface_CreateDefault() IMyInterface {
  // или я видел имена CreateDefaultIMyInterface, что более в каноне
  return newMyDefaultImpl(_DEFAULT_I) // вот собственно наш дефолт
}

/////////////////////////////////////////////////////////////////
// НА ПРАВАХ ХАКА - как все же заставить golang 
// иметь "компаньоны"

// а можно даже исполнить синтаксически схоже c Kotlin
// можно будет вызывать именно как IMyInterface_().createDefault()
// напомню, что это `package private` - все что не с больших букв
type _myInterfaceCompanion struct {} // пустая структура как псевдо тип
func (_ _myInterfaceCompanion) CreateDefault() IMyInterface {
  return newMyDefaultImpl(10) // вот собственно наш дефолт
}
func IMyInterface_() _myInterfaceCompanion { return _myInterfaceCompanion{}}
// все, теперь снаружи можно так my:= IMyInterface_().CreateDefault()
///////////////////////////////////////////////////////////////

// а вот это НЕ УТИНАЯ типизация - явное требование компилятору
// еще при сборке проверить, что *_MyDefaultImpl поддерживает IMyInterface
var _ IMyInterface = &_MyDefaultImpl{}

Теперь в клиентском коде мы получаем поведение, подобное Kotlin классу

В kotlin:

import my
val x : IMyInterface = IMyInterface.createDefault()
import "my"

var x my.IMyInterface = my.IMyInterface_().CreateDefault()
//или  var x my.IMyInterface = my.IMyInterface_CreateDefault()

Более того, можно довести совсем до Kotlin-стайла, что правда резко расходится с каноном:

...
type _myInterfaceCompanion struct {} // пустая структура как псевдо тип
func (_ _myInterfaceCompanion) CreateDefault() IMyInterface {
  return newMyDefaultImpl(10) // вот собственно наш дефолт
}
// полный антипаттерн - глобальная переменная! 
// но синтаксически можно
var IMyInterface_  _myInterfaceCompanion = _myInterfaceCompanion{}

Ну и тогда вообще до смешения

// именно так, с точкой, по аналогии с импортами Java чтобы было поведение
import . "my" 
// все отличие от Kotlin только что подчеркивание вставили, иначе
// коллизия имен
var x IMyInterface = IMyInterface_.CreateDefault()

Итак резюмирую:

Все что связано со свойствами, конструкторами, компаньонами, статическими методами, инициализацией, фабричными методами, ленивыми свойствами — без особых сложностей переносится и можно сделать в итоге и более канонично, но менее похоже на Kotlin (по внешнему API), а можно менее канонично, но почти один в один по внешнему виду

Обработка ошибок

Тот случай, когда лучше не пытаться переносить подход Java/Kotlin в Golang и от этого все выиграют.

Может когда-то напишу про это, но я точно из лагеря тех, кто считает, что с появлением исключений и особенно их структурированной обработки (try/catch) и особенно с finally блоком в этой структурной обработке — эволюция пошла не туда. И меня нисколько не удивляет, что в новых языках типа Golang или Rust, есть паники, есть ошибки, паники могут тоже развертывать стек и так или иначе перехватывать (recovery в golang и catchUnwinded в Rust) — тем не менее там нет и близко try/catch/finally

Соответственно при переносах обработки ошибок можно действовать так

Перенос throw

Тут 2 ситуации — определитесь — это действительно «исключение», которое сигнализирует, что программа загнала себя в ситуацию, с которой не может справиться и может проще ее завершить чем продолжать работу. В этом случае эквивалентом будет panic

fun myFun() {
    if (callSomething() == null) {
       throw Exception("все у нас вообще null, такого не может быть!")      
    }
}
fun main() {
    myFun() // никакой обработки try/catch
}
funс MyFun() {
    if (СallSomething() == nil) {
       panic("все у нас вообще nil, такого не может быть!")      
    }
}
funс main() {
    MyFun() // никакой обработки try/catch
}

Но бывает и другая ситуация, когда исключение это по сути «сигнал», который следует обработать. Тогда в терминах, непривичных джавистам — это не «исключение» и не «паника», а «ошибка». То есть четко различаются паники - нечто, что скорее всего не обработать и реальный сбой, — приводит скорее всего к завершению приложения и ошибки — некие сигналы о каких-то сбоях и проблемах, которые можно как обработать, так и проигнорировать и за это уже отвечает вызывающий код

fun myFun() {
    if (callSomething() == null) {
       throw Exception("все у нас вообще null, такого не может быть!")      
    }
}
fun main() {
   try{
    myFun()
   }catch(e: Throwable) { // тут у нас реакция с легким сайдэффектом и игнором
     println(e.Message)
   } 
}

то лучше использовать внятное

func MyFun() error {
  if (CallSomething() == null) {
       return fmt.Errorf("все у нас вообще null, такого не может быть!")      
    }
  return nil
}

func main() {
   err := MyFun()
   if err != nil {
     fmt.Println(e.Error())
   }
}

Главный совет. Обработка ошибок — это то, что в Java/Kotlin/C++ и еще много где исполнено на «исключениях» и их структурной обработке. Эта история собирает все больше критики и от нее все чаще отказываются (в новых языках). Golang не пригоден работать в модели и делать panic (a-ka throw) чтобы потом делать recover (a-ka catch) и где надо и не надо писать defer (a-ka finally) — это будет самый плохой пример попытки натянуть ежа на уже, хуже чем с приведенными выше «компаньонами».

Мало того, что если вы переносите в Golang — лучше чуть поработать и сделать в его модели. Если вы наоборот ИЗ Golang переносите в Kotlin лучше СОХРАНИТЬ эту модель обработки — благо в Kotlin из коробки есть Result и в целом нет проблемы сделать обработку ошибок и без выбрасывания исключений.

Что лучше не пытаться переносить, а лучше упростить

Как мы увидели выше — Golang в принципе позволяет работать близко к ООП, да и вообще не требует такой массы компромиссов c Java/Kotlin как например порты на С. Но есть вещи, которые идеологически отличают Golang и Kotlin — причем настолько, что при переносе из Kotlin в Golang мы практически гарантировано будем исключать некоторые вещи, как не поддерживаемые, а при переносе из Golang в Koltin наоброт переписывать или добавлять.

Главное что отличает Golang от Kotlin, прямо в их манифестах:

  • Kotlin — это про всеядность и сахар (нет единого стиля, расширения, делегаты, перегрузка операторов, инфикс функции, DSL, tail function arg, компаньоны, объекты, смарткасты, условия как выражения, no-return, …)

  • Golang — это про унификацию и простоту (один формат, один вариант инструкции, решения в лоб, про все)

По факту это языки с диаметрально разной идеологией!

Соответственно из Kotlin в golang не переносятся вещи, которые заведомо сделаны для сахара и хитрых решений. И если обобщить — то это все, что связано с DSL и всякими «котлинскими штучками»

  • не будет никакого tailrec — если надо рубите «хвосты» сами

  • никакой перегрузки операторов — заменяйте просто функциями

  • никаких «псевдоблоков» новых в языке за счет функций последним параметров

  • никаких инфиксов

  • более менее можно играть в «делегаты» — lazy например был выше показан -, но язык Вас в этом не поддержит, скорее всего вы не будете делегировать ради делегирования, а сделаете более в лоб (благо композиция как паттерн в golang как раз на высоте)

Рефлексия…

Чем меньше рефлексии тем лучше. Всюду. Точка.
P.S. Особенно в голанге

Если все решение у вас в Kotlin опирается на рефлексию, с учетом полиморфизма, с анализом метаданных свойств, методов и прочего — скорее всего вы это никак не перенесете. И в целом в go рефлексия это примитивные апкасты до интерфейса и не более того (точнее там есть еще всякий typeOf и прочее), но будем честны — рефлексия в Java и в Kotlin особенно — на порядок просто сложнее и многофункциональнее.

Если допустим вы пытаетесь (а у нас такая история есть) портировать какие-то валидаторы, расширенные сериализаторы, какие-то ORM-подобные штуки, которые как правило сильно опираются на аннотации, на рефлексию, — то скорее всего это обречено на провал.

В golang тоже есть свои аннотации (не объекты, а такие скорее строки структурированные), есть немного рефлексии -, но ее Вам скорее не хватит.

Скорее всего придется все планировать с нуля или вообще альтернативно к вопросу подойти — упростить логику или на кодогенерации построить или еще как-то

Корутины vs Горутины

Тут отдельный разговор и не на один час и наверное тут, в этой статье я не буду сильно останавливаться, так как у нас речь больше шла про синтаксис, а не про рантаймы.

В целом имея уже некоторый опыт переноса я бы свел к двум вещам

  • В Golang горутины — это очень просто, на два щелчка, более менее понятно как оркестрировать, но при этом все как по рельсам — ни вправо ни влево и без возможности влиять на то как это все работает

  • В Kotlin корутины — с одной стороны часть языка (suspend), но это именно «приостановка» и асинхронщина в чистом своем виде, а корутины — это отдельная библиотека, надостройка, которая уже занимается парарлеллизмом и там собственно оркестрацией как таковой. В итоге все сложнее, многословнее, зато можно кучу всяких финтов делать

Так вот

  • Golang относительно легко переносится в Kotlin, только в Kotlin больше кода обвязки возникает

  • Из Kotlin же в Golang переносится только с серьезным упрощением и перепланированием

То есть если у вас просто GlobalScope.launch {... } и в лучшем случае потом join, то скорее всего вы просто это перепишите на go func() {...}() и там WaitGroup

Но если у вас собственные пулы потоков, связанные скоупы, донастроенные диспетчеры, ConflateChannel и тому подобное… то простите — простым и в лоб Ваш порт быть не может.

В таком случае точно придется пойти на какие-то упрощения, компромиссы и перепланирование.

Ссылки, значения, копии

Есть несколько концепций, которые Kotlin-истам, джавистам даются почему-то обычно со скрипом.

Первая — это передача по ссылке и по значению, а также связанные вещи — разыменование, копирование, почему интерфейс в голанге это и не ссылка и не значение и прочее. Естественно это то, что надо просто знать и понимать.

Естественно, что для тех кто пишет на C/C++/Rust нет никакой сложности в том, чтобы понять что такое & и * и что такое передача по значению по указателю или по ссылке (и кстати в чем отличия указателей и ссылок)

Но для тех, кто привык что все по ссылке (или вообще об этом не задумаывается), а это в обещм и целом Java, Kotlin, C# (в общем случае), JavaScript, Python при начале работы на Go лучше к чему приучиться:

  • все, что связано со структурами по умолчанию делайте на ссылках

  • переводите на передачу по значению и на использование значений — только если четко понимаете зачем, почему

Любой гофер опытный будет плеваться от этого совета, потому что «так в нативе не принято». Ты обычно итак понимаешь в каких контекстах тебе нужно что и соответственно это и применяешь. И прогоняешь через профайлер. Но для использования как второго языка и для большего сходства именно такой совет

То есть вот так лучше не делать (если не уверены)


type Foo stuct {
   X int
}
type Bar struct {
    F Foo
    Y int
}
func NewFoo () Foo {...}
func (b Bar) Do(f Foo) {...}

если вы не понимаете что при этом делается и когда — то рано или поздно напоретесь, что получаете неожиданное поведение из-за того что всюду сплошные копии и каких-то общих объектов нет. А джавистам привычнее как раз работать с общими объектами. Поэтому лучше

type Foo stuct {
   X int
}
type Bar struct {
    F *Foo
    Y int
}
func NewFoo () *Foo {...}
func (b *Bar) Do(f *Foo) {...}

да, скорее всего в каких-то случаях это будет менее эффктивно из-за аллокаций и смотрится может как-то неприятно — много & и *. Но зато оно ведет себя так как этого обычно ожидают от экземпляров джависты.

При этом сделать копию в golang ВООБЩЕ НЕ ПРОБЛЕМА

func I_Will_Return_Copy(foo *Foo) *Foo {
     var cpy Foo = *foo // вот и все снятие копии
     return &cpy // вернули от нее ссылку
}

собственно никаких data class и не надо для go для copy, так как нет «классов», а структуры — это то, что спокойно копируется по памяти «из коробки»

Утиная типизация (интерфейсы)

Вторая многим непонятная концепция — утиная типизация при имплементации интерфейсов.

Ну тут не только джависты, но и почти все немного поначалу недоумевают… Такой концепции интерфейсов как в Golang почти нигде нет, только вот в Python и Golang. Но когда речь идет про Python — то там все легко это воспринимают «динамический же язык, что с него взять, понятно все там как-то налету кастуется» и в языке со строгой типизацией это тяжело воспринимается.

Кто не знает «утиная» это следующее: утка не знает, что она «утка», она вообще ничего может не знать ни о чем, но она при этом КРЯКАЕТ (func (u *Me) Kryack ()), и ПЛАВАЕТ (func (u *Me) Swim ()) — поэтому для орнитолога (вызывающая сторона) она УТКА (type IDuck interface { Kryack () ; Swim () }) — независимо от знаний утки. То есть нечто имеет интерфейс «утка» не потому что в ней это определено, а потому что кто-то решил что она соответствует… как -то так

Вот собственно и вся концепция. Владелец интерфейса — его клиент. Он определяет там, что должен уметь тип. Если какой-то тип соответствует — значит он реализует интерфейс. Сам тип при этом может ничего про существование интерфейса не знать.

Свежо, методично, модно, молодежно…, но непривычно.

Для разработчиков на Java, особенно на Java EE или Spring мир выглядит совсем не так, а наоборот:

  • все нужные интерфейсы за тебя давно написаны — смотри Spring Reference © Bloody Enterprise

  • твоя задача их строго выполнить © Bloody Enterprise

Соответственно вся эта утиная история вообще кажется дикой на первый взгляд.

И для начала введу вас обратно в зону комфорта, напомню, что на Go можно и не по-утиному, а вполне и по-спринговому:

//   /enterprise/core/ifaces.go
package ifaces
type IRepository interface { 
  GetAll() []any
}
type IRefreshable interface {
  IsObsolete() bool
}
---------------------------------------------
//  /myplugin/plugins.go
package plugins
import "enterprise/core/ifaces"

// это наш компонент
type MyRefreshableRepositoryImpl struct {}
// вот вполне пока утиная реализация - мы тут нигде не упоминаем 
// интерфейсы и не можем быть уверены что все что хотели реализовали
func (r *MyRefreshableRepositoryImpl) GetAll() []any {...}
func (r *MyRefreshableRepositoryImpl) IsObsolete() bool {...}

// а вот это по сути "указание" компилятору провести 
// на этапе компиляции, что мы соответствуем нужным интерфейсам
// получается что-то вроде class MyRefreshableRepositoryImpl: IRepository, IRefreshable
var _ ifaces.IRepository = &MyRefreshableRepositoryImpl{}
var _ ifaces.IRefreshable = &MyRefreshableRepositoryImpl{}

думаю, что любого джависта это уже должно устроить, хотя это немного как хак выглядит.

От себя — в целом утиная типизация — это то, что раскрывается постепенно, в какой-то момент в каком-то модуле вы можете понять, что вот тут вот как раз она и пригодилась и в ситуации без нее — все было бы хуже. Так что в целом утиная типизация — концепция хотя и экзотическая несколько на общем фоне -, но не особо напрягающая, а иногда выручающая.

Тесты

Тут все просто. Если вы используете в своем решении на Kotlin стандартные тестовые фреймворки с базой на JUnit, — Kotest, Kotlintest, то скорее всего никаких сложностей переносить на Golang тесты у вас не будет.

Обратное тоже верно — тесты на testing переносятся без проблем в целевые фреймворки на JVM

Что нужно наверное учесть

  1. Практически всегда надо затаскивать в проект библиотеку https://pkg.go.dev/github.com/stretchr/testify/assert — это из тех пакетов, которым место в стандартном наборе пакетов, но исторически живет где-то отдельно

  2. Многие не смотрят что там внутри testing.T и очень обедняют свои тесты при переносе, а вообще-то в этой структуре есть метод Run который позволяет стартовать дочерние тест — соответственно вы можете спокойно обеспечить себе дизайн с пре- и пост- перехватчиками, с иерархией тестов, с порождением тестов или табличными тестами — все это ИЗ КОРОБКИ

  3. Многие не знают, что кроме тестов в Golang встроены и бенчмарки — поэтому если у вас использовались какие-то бенчмарки под JVM в тестах или какая-то кустарщина — в принципе тоже легко переносится

Заключение

Ну вот такая вышла статейка на нашу местную злобу дня.

Уверен, что многое еще чего можно написать на эту тему, но подкопилась пока именно такая подборка замечаний, зато по горячим так сказать следам…

Надеюсь, что кому-то еще это может оказаться полезным. Также надеюсь на конструктивную критику и замечания от неравнодушных

© Habrahabr.ru