Предистория


Под впечатлением статей про Sprite Kit и про GestureRecognizer возникла идея портировать простенькое приложение себе на телефон. А так как там достаточно часто используются структуры CGPoint, то озадачился изучением перегрузки операторов в Swift и подсмотрел тут.

Реализация


В несколько шагов создал себе некоторую «библиотеку» операторов перегрузки для удобной работы с выражениями для структур CGPoint. Заодно на каждом шаге потестировал тестирование в XCode.

Фактически реализована полная алгебра операций для линейного пространства векторов (структуры CGPoint), скаляров (CGFloat) и частично операторов (CGAffineTransform).

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

Конечно, примеров использования перегрузки на вымышленных ситуациях достаточно много, но данная «библиотека операторов» возможно окажется:

  1. чуть более конкретным примером,
  2. готовым решением, дающим экономии пары часов рутинного набора и отладки простейшего кода (листинг исходника целиком приведен в конце).

Шаг за шагом


Шаг 1: операторы сложения/вычитания векторов
Стоит обратить внимание, как во втором случае с помощью prefix указывается на унарность оператора-префикса.

func +(left: CGPoint, right: CGPoint)->CGPoint{
	return CGPoint(x: left.x+right.x, y: left.y+right.y)
}
prefix func -(right: CGPoint)->CGPoint{
	return CGPoint(x: -right.x, y: -right.y)
}
func -(left: CGPoint, right: CGPoint)->CGPoint{
	return left + (-right)
}


Шаг 2: умножение и деление вектора на скаляр
Здесь стоит обратить внимание на то, что первые два оператора образуют «симметрию» умножения скаляра и вектора.

func *(left: CGPoint, right: CGFloat)->CGPoint{
	return CGPoint(x: left.x*right, y: left.y*right)
}
func *(left: CGFloat, right: CGPoint)->CGPoint{
	return right*left
}
func /(left: CGPoint, right: CGFloat)->CGPoint{
	return left*(1/right)
}


Шаг 3: совмещение вышеперечисленных операций с присвоением
Важно обратить внимание, как с помощью inout описывается изменяемость левого аргумента, что необходимо для операций с присвоением.

func +=(inout left:CGPoint, right:CGPoint){
	left = left+right
}
func -=(inout left:CGPoint, right:CGPoint){
	left = left-right
}
func *=(inout left:CGPoint, right:CGFloat){
	left = left*right
}
func /=(inout left:CGPoint, right:CGFloat){
	left = left/right
}


Шаг 4: скалярное и 'смешанное' произведение
Очень полезные вещи, так как первое произведение дает скаляр и пропорционально косинусу угла между векторами, а второе произведение является псевдоскаляром (то есть меняет знак при перестановке множителей) и пропорционально синусу угла между векторами (жест «вращение» вычисляет величину угла вероятнее всего как раз на базе этого выражения).

func *(left: CGPoint, right: CGPoint)->CGFloat{
	return left.x*right.x + left.y*right.y
}
func ^(left: CGPoint, right: CGPoint)->CGFloat{
	return left.x*right.y - left.y*right.x
}


Шаг 5: произведение вектора на оператор аффинного преобразования
Здесь порядка ради вводится только умножение вектора на оператор справа.

func *(left: CGPoint, right: CGAffineTransform)->CGPoint{
	return CGPointApplyAffineTransform(left, right)
}
func *=(inout left:CGPoint, right:CGAffineTransform){
	left = left*right
}


Шаг 6: произведение операторов аффинного преобразования
func *(left: CGAffineTransform, right: CGAffineTransform)->CGAffineTransform{
	return CGAffineTransformConcat(left, right)
}
func *=(inout left:CGAffineTransform, right:CGAffineTransform){
	left = left*right
}


Заключение


Приведенный пример являет необходимым и достаточным набором в текущем понимании моих потребностей. В частности, поэтому не реализована алгебра для матриц и для аналогичных структур (напр., CGSize).

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

Весь код целиком
Это можно уже копировать непосредственно в проект.

import Foundation

func +(left: CGPoint, right: CGPoint)->CGPoint{
	return CGPoint(x: left.x+right.x, y: left.y+right.y)
}
prefix func -(right: CGPoint)->CGPoint{
	return CGPoint(x: -right.x, y: -right.y)
}
func -(left: CGPoint, right: CGPoint)->CGPoint{
	return left + (-right)
}
///////////////////////////////////////////////////

func *(left: CGPoint, right: CGFloat)->CGPoint{
	return CGPoint(x: left.x*right, y: left.y*right)
}
func *(left: CGFloat, right: CGPoint)->CGPoint{
	return right*left
}
func /(left: CGPoint, right: CGFloat)->CGPoint{
	return left*(1/right)
}
///////////////////////////////////////////////////

func +=(inout left:CGPoint, right:CGPoint){
	left = left+right
}
func -=(inout left:CGPoint, right:CGPoint){
	left = left-right
}
func *=(inout left:CGPoint, right:CGFloat){
	left = left*right
}
func /=(inout left:CGPoint, right:CGFloat){
	left = left/right
}
///////////////////////////////////////////////////
///////////////////////////////////////////////////

func *(left: CGPoint, right: CGPoint)->CGFloat{
	return left.x*right.x + left.y*right.y
}
func /(left: CGPoint, right: CGPoint)->CGFloat{
	return left.x*right.y - left.y*right.x
}
///////////////////////////////////////////////////
///////////////////////////////////////////////////

func *(left: CGPoint, right: CGAffineTransform)->CGPoint{
	return CGPointApplyAffineTransform(left, right)
}
func *=(inout left:CGPoint, right:CGAffineTransform){
	left = left*right
}
///////////////////////////////////////////////////

func *(left: CGAffineTransform, right: CGAffineTransform)->CGAffineTransform{
	return CGAffineTransformConcat(left, right)
}
func *=(inout left:CGAffineTransform, right:CGAffineTransform){
	left = left*right
}

Комментарии (8)


  1. semenyakinVS
    25.11.2015 14:10

    Встречал разные мнения о перегруженных операторах. Кто-то считает что они позволяют сократить код и сделать его более дескриптивным. Кто-то замечал что они наоборот вносят сумятицу в понимание — не так ясны типы операндов и сложнее оценить сложность вычисления выражения.

    Полтора года работал над игровым проектом, в котором, конечно, активно использовались операции над векторами, но не использовались перегруженные операторы. Я был озадачен, так как считал что перегруженные операторы это круто — но мне сказали что так яснее происходящее в коде. Дома я писал свой pet-движок, в котором использовал перегруженные операторы… И, знаете, в какой-то момент (а именно — при написании выражения, в котором участвовало больше двух операторов) я понял, что ребята на работе были правы. Действительно, когда операция записывается в виде функции понимание яснее. Помимо этого, при использовании функций [а не операторов] можно передавать вектора по ссылке и не порождать временные объекты (я понимаю, copy eligion, оптимизации компиляции и всё такое, но семантика кода есть семантика кода).

    П.С.: Всё сказанное выше касалось С++. Возможно, в Swifth будет по-другому. На мой взгляд, указанные проблемы выглядят как универсальные.


    1. 25.11.2015 18:02

      Запутать действительно можно что угодно, но с разной степенью усилий. Предлагаю обсудить:

      1) на сколько я владею вопросом, не касаясь формы записи, операторные функции и просто функции ничем не отличаются в описании: в обоих случаях, если не заглядывать «под капот», то можно только предполагать, какая использована реализация.

      2) на сколько мне известно, в Cpp способы передачи аргументов и возврата значений регулируются идентично и для функций и для операторов: хоть по ссылке, хоть по значению. Единственная разница в том, что операторные функции можно вызывать и явно как функцию (a.operator+(&b)) и неявно через операторную запись (a+b). При чем, компилятор, вроде бы, всегда все сводит к первому варианту. А вот для методов/функций доступен только первый вариант нотации.

      3) если не сложно, то хотелось бы увидеть «методичную» реализацию на конкретном примере. Возможно, на конкретике мне станет понятнее, так как попробовав сейчас посравнивать оба варианта, создалось четкое ощущение, что «методичный» вариант записи с трудом конкурирует по понятности с «операторным». Единственно, временные объекты пришлось выписывать явно, что «операторная» нотация конечно несколько маскирует от пользователя — это факт, тут надо понимать, что за записью кроется.

      В общем, предлагаю такой пример для упражнения:

      float t, k; // числа
      Point2 a, b, c; // векторы с только двумя координатами икс и игрек
      Trasfoator M; // какой-то оператор
      a = b*t + c*k*M; // запись с перегрузкой
      


      1. semenyakinVS
        26.11.2015 00:13
        +1

        a = b*t + c*k*M;


        Если такой код будет где-то не рядом с объявлением переменных — трудно догадаться что там вообще происходит. Нужно знать типы объектов… Впрочем, можно прикинуть как это будет выглядеть в случаи с записью без операторов:

        a.assign(b);
        a.mul(t);
        
        Point tmp;
        tmp.set(c).scolar_mul(k).matrix_mul(M);
        
        a.sum(tmp);
        


        Ага, выглядит как ассмеблерная запись вашего кода… Громоздко. Но тут прозрачны все действия, которые происходят с объектами.

        … Впрочем, вероятно, это просто дело вкуса.


        1. semenyakinVS
          26.11.2015 02:31

          a.mul(t);


          Тут scolar_mul(), конечно.


          1. 27.11.2015 23:42

            да, иначе шило на мыло)


        1. semenyakinVS
          26.11.2015 12:46

          tmp.set


          И вместо этого assign ( c );


        1. 28.11.2015 03:12

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

          И все же, тут есть над чем поломать копья.



          Если такой код будет где-то не рядом с объявлением переменных

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

          Но стоит ли так радикально ставить вопрос: либо только методы, либо только операторы?
          Мне кажется, что истина посередине. Например: можно реализовать только однородные операторы (сложение/вычитание), которые подразумевают оба операнда однотипными.
          Сделает это код более компактным, чем только реализуя через методы? Возможно.
          Будет такой код более понятным относительно типов с операндами? Так же возможно.
          Если по мне, то гибридный подход уже дает как минимум «probably Win probably Win»



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

          Спасибо! Получилось, как у меня.
          Здесь есть двоякий момент.
          1. 1) если создание временного объекта неизбежно (а так случается), то это придется делать вручную. В итоге:
            кол-во действий увеличится (объект нужно объявить и создать),
            способов ошибиться прибавиться (надо будет при поддержке кода отслеживать большее число элементов кода),
            читаемость кода ухудшится (мы будем видеть доп.информацию о том, что делает компилятор в перемешку с тем, что собственно отражает логику замысла разработчика).
            Но соптимизировать будет нечего.
          2. 2) а вот если создание временного объекта можно избежать, то это станет несколько очевиднее, чем в операторной записи. Например:
            a = b+c

            выполняет на одну операцию больше, чем
            a=b
            a+=c

            Но это друга уже дилемма: на этапе Отладки предпочтительнее видеть Что хотим Мы сделать, а на этапе Оптимизации (если она нужна в данном куске кода) Как это будет Реализовывать Компилятор.
            Бытует мнение, что всему свое время: для упрощения Отладки желательно иметь максимально понятную и прозрачную реализацию Задуманной логики, а на этапе Оптимизации желательно иметь Отлаженныйоттестированный) код, который заменяется на аналогичный оптимизированный, а аналогичность Отлаженного и Оптимизированного кода опять же тестится достаточно легко и элегантно… но это друга тема.


          В любом случае! Если не касаться проблем из-за оптимизации во время отладки, то и тут есть компромисы вместо крайностей: если не определять бинарные операторы, а только унарные с присвоением, то мы получим невозможность неявного создания временных объектов и это будет, как я понимаю, полная аналогия с подходом через методы.
          Причем, это тоже можно реализовать не радикально: либо выкидываем бинарные операторы, либо используем, а гибко:
          объявляем и полный набор операторов, но неоптимальные/медленные/итд выносим в блок или файл, который откючается (у меня автоматически), если компиляция переводиться в режим Оптимизации.
          То есть, пока отлаживаем — пишем нормальные однострочные понятные формулы почти в том виде, как они в тетрадке/презентации/учебнике записаны.
          А когда оптимизируем — компилятор перестанет понимать некоторые места, и мы их заменяем на аналогичные (в чем помогут тесты) развернутые/оптимизированные/использующие_регистры/итд реализации.



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

          Аблсолютно!
          Для оптимизации это крайне важно — видеть (или понимать) действия компилятора «под капотом».
          И тут надо либо всегда под бинарным оператором иметь привычку видеть создание объекта (что требует дополнительной вдумчивости), либо иметь явно выписанные элементарные действия (тут уж никуда не деться).
          Вопрос только в том, сколько нужно потратить времени и сил на этапе Отладки, чтобы из такого кода «дизассемблировать» и рассмотреть Логику, ради которой это все затеяно? А сколько потом нужно времени, чтобы понять, что неправильно понял, и потом все же надеяться, что с энного раза все же ее правильно понял? А как такой код поддерживать? Как немного скорректировать в процессе доводок? Как найти ошибку? И далее по изодранному списку вечных проблем… (ради ухода от которых решили перейти с машинных кодов на компиляторы).
          В общем, можно на эти вопросы давать разные ответы с разной пропорцией субъективность/объективность.
          Но именно эти вопросы лично меня заставили когда-то признать поражение моей Ассемблерной религии и перейти на С++. Поэтому фараза
          Ага, выглядит как ассмеблерная запись вашего кода
          очень даже уместна. В данном случае это действительно шаг в ту сторону.
          Нужен этот шаг?
          На этапе Оптимизации — несомненно!
          Нужно ли заниматься Оптимизацией неОтлаженного кода?
          Тут надо себе ответить самостоятельно.



          Ну и еще пара моментов, отчасти как повторение-переформулировка
          1. Наверное не стоит на уровне глобальной идеологии унифицировать противостояние операторов и методов
            Для некоторых мега-классов операторы могут только вредить, не иметь внятного смысла или хотя бы возможности быть оптимальными не только в компиляции, но и в плане читабельности.
            А для некоторых нано-структур даже Избегание создания временных объектов — это больше зло, чем накладные расходы из-за их использования, особенно, если эти классы-структуры могут целиком передаваться в паре регистров процессора.
          2. По моему продолжительному, но скромному опыту, складывается впечатление, что Оптимизировать во время Отладки (тем более Разработки) — это не очень прямой путь: для Отладки нужна прозрачная просматриваемая логика, а для Оптимизации нужен Отлаженный модуль/приложение, которое вот теперь действительно предстоит закодировать в самом прямом смысле!!


    1. 25.11.2015 18:06
      +1

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