Skip to content

On funcs

Шаг 1. Избавляемся от интерфейсов

Интерфейсы с одним методом эквивалентны указателю на функцию:

type Reader interface {
    Read([]byte) (int, error)
}
// becomes
type Reader = func([]byte) (int, error)

Интерфейсы с несколькими методами эквивалентны структуре с указателями на функции:

type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}
// becomes
type Interface[Self any] struct {
    Len func(Self) int
    Less func(self Self, i, j int) bool
    Swap func(self Self, i, j int)
}

Преимущества:

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

Недостатки:

  • больше букв для вызова метода: item.Less(slice, i, j) вместо slice.Less(i, j)
  • не хранится информация об исходном типе, точнее хранится в дженерике и фокусы с type-assertion-ами перестают работать, что как по мне к лучшему
  • нельзя использовать как констрейнты в дженериках
  • Go все еще позволяет не заполнять часть структуры если юзать explicit филды. Поэтому логичнее их не использовать.
  • нужно явно "приводить" к интерфейсу: вместо sort.Sort(slice) будет что-то типо:
    // declaration
    func Sort[T any](vtable Interface[T], collection T)
    // usage
    // var slice *MySlice
    sort.Sort(
        sort.Interface[*MySlice]{(*MySlice).Len, (*MySlice).Less, (*MySlice).Swap},
        slice,
    )
    

Шаг 2. Избавляемся от методов

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

// вместо
func (s *MySlice) Len() int {return len(s.slice)}
// пишем
func MySliceLen(s *MySlice) int {return len(s.slice)}

Преимущества:

  • чуть меньше букв в "интерфейсах":
    sort.Sort(
        sort.Interface[*MySlice]{MySliceLen, MySliceLess, MySliceSwap},
        slice,
    )
    
  • делает концепт методов как часть языка ненужным
  • при вызове something.Do() непонятно, будет ли передан указатель на ресивер или копия, и следовательно может ли ресивер измениться. С функцией имеем либо SomethingDo(something) либо SomethingDo(&something) явно указывая ресивер при вызове. Хоть и само понятие ресивера перестает иметь смысл.
  • пропадает вопрос, когда делать метод, а когда функцию

Недостатки:

  • придется писать чуть больше букв при вызове
  • пропадает смысл использовать констрейнты кроме comparable, any и конкретных типов

Шаг 3. Избавляемся от именованных функций

По сути

func MySliceLen(s *MySlice) int {return len(s.slice)}
это ничто иное, как
var MySliceLen = func(s *MySlice) int {return len(s.slice)}

Преимущества:

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

Недостатки:

  • нельзя сделать const чтобы гарантировать статический диспатч. это ограничение компилятора и никак не фиксится (вне компилятора)
  • лишние ключевые слова var и func все еще существуют, хоть и не имеют смысла, то же самое (в теории) можно было бы выразить так:
    MySliceLen := (s *MySlice) int {return len(s.slice)}
    

Шаг 4. Манкипатчинг

Теперь нам доступен манкипатчинг любой функции:

oldLen := MySliceLen
defer func() {MySliceLen = oldLen}()

calls := 0
MySliceLen = func(s *MySlice) int {
    calls++
    return len(s.slice)
}

sort.Sort(
    sort.Interface[*MySlice]{MySliceLen, MySliceLess, MySliceSwap},
    slice,
)
assert(calls == 1)

Патчинг можно вынести в функцию вида

func UsePatch[F any](t *testing.T, orig *F, sub F) {
    oldF := *orig
    *orig = sub
    t.Cleanup(func() {*orig = oldF})
}

// in test
calls := 0
UsePatch(t, &MySliceLen, func(s *MySlice) int {
    calls++
    return len(s.slice)
})

Или проще, если мы непосредственно тестим функцию с интерфейсом, а не функцию, которая использует функцию MySliceLen где-то внутри

calls := 0
mySliceLen := func(s *MySlice) int {
    calls++
    return len(s.slice)
}

sort.Sort(
    // используем патченую функцию длины
    sort.Interface[*MySlice]{mySliceLen, MySliceLess, MySliceSwap},
    slice,
)
assert(calls == 1)

Тем самым отпадает нужность в моках, т.к. моки заменяются на ручной временный патч нужных функций. Да и вообще становится возможным трекать любой вызов любой функции (публичной), а не только методы, которые используются через интерфейс. А еще можно патчить методы без интерфейса. А еще можно трекать поведение старой функции, не меняя ее поведение полностью:

calls := 0
oldLen := MySliceLen
UsePatch(t, &MySliceLen, func(s *MySlice) int {
    calls++
    return oldLen(s)
})

Преимущества:

  • все описанные преимущества патчинга + функций-переменных + отсутствия интерфейсов

Недостатки:

  • неконвенционально
  • неприменимо к коду, который не использует такой стиль, например сторонней библиотеке
  • (конкретно в Go) все вызовы потенциально становятся виртуальными, пока они не сделают нормальный оптимизатор, в частности инлайн и девиртуализацию и трекинг глобальных переменных