LINUX.ORG.RU

Давайте поговорим о трейтах

 , , , ,


0

2

Вот я смотрю очередной раз на трейты. Первоистчник говорит:

Traits do not specify any state variables, and the methods provided by traits never directly access state variables.

Далее смотрим первую попавшуюся статью по PHP и видим:

trait Id
{
    protected $id;
    public function getId()
    {
        return $this->id;
    }
}

Т.е., в PHP трейты - это не трейты, я верно понял?

И теперь вот что я хотел узнать - как соотносятся по производительности трейты с одиночным наследованием реализации. Тут я слегка упираюсь в то, что я не понимаю, как реализованы вирт. ф-ии в С++ при множественном наследовании. Как я понял, если класс Child наследует от Parent1 и Parent2, и мы хотим вызвать виртуальный метод Parent1::Method, то мы должны где-то найти указатель на этот метод. Метод ищется в VMT и Child содержит 2 VMT, для каждого из Parent.

И получается, чтобы найти этот VMT, нам нужно сначала вызвать некую ф-ю VMTOffsetOfParent1(Child). Эта функция может вызываться для всех потомков Parent1, и может принимать на вход только какое-то число, идентификатор класса, а возвращать она должна указатель. Такое отображение можно реализовать только с помощью хеш-таблицы, b-дерева или иного объекта с логарифмическим временем доступа по отношению к количеству потомков Parent1.

По сравнению с этим, при одиночном наследовании вызов вирт.метода осуществляется за линейное время.

Если Parent1 и Parent2 становятся трейтами, то опять же нам нужно вызвать функцию GetParent(Child), которая опять же требует хеш-таблицы для своей реализации, со временем доступа, логарифмическим по отношению к количеству классов, воплощающих этот трейт.

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

Наверное, я зря сюда приплёл вирт. ф-ии, поскольку затратная операция здесь - скорее dynamic_cast. Вот нашёл статейку, как подобрать хеш-функцию, чтобы не было конфликтов между классами.

Верно я мыслю или нет? В свете этого, верно ли, с точки зрения производительности, решение отказаться от наследования реализации, принятое в Rust и Golang?

А зачем мне это нужно: нужно быстро придумать, как реализовать ООП в Яре. Понятно, что нужно включить минимум для начала (потому что версия только 0.4). Но любой минимум потом оказывается более, чем минимальным, и включив что-то в язык, потом уже не избавишься. Поэтому тэг «Яр» здесь должен стоять, уж извините. Он особенно хорош тем, что те, кому я надоел, ставят этот тег в список игнорируемых.

★★★★★

Последнее исправление: den73 (всего исправлений: 2)
Ответ на: комментарий от den73

Сложение-то произвести всё равно надо во время выполнения, если в функцию пришёл неизвестный указатель на объект.

ЕМНИП нет, можно обойтись случаями времени компиляции.

см. раскрутку макросов BEGIN_COM_MAP(CMyATLCOMclass)...END_COM_MAP() в ATL, а также ещё вот пример: ООСУБД GOODS Константина Книжника — читай его диссер про реализацию метаобъектного протокола на С++98 (там МОП реализован через рефлексию, рефлексия через трейты с битхаками полей класса; МОП применяется для АОП, различных аспектов ООСУБД — транзакций, многопоточности, стратегий хранения, и т.п.)

и исходники GOODS — там довольно просто и понятно, даже проще чем в ATL :-)

anonymous
()
Ответ на: комментарий от anonymous

исходники GOODS: goods/src/mop.cxx goods/src/javamop.cxx

шах и мат, борщехлебы!

настоящий программист на С++ всегда может написать правило Гринспуна метаобъектный протокол на С++ :-)))

просто потому, что он — настоящий программист.

anonymous
()
Ответ на: комментарий от anonymous

а власти скрывают

то есть, в памяти раскладка экземпляра класса такова, что 1:1 методы становятся указателями на интерфейсы COM

вот и думай после этого о теориях заговора: что мелкософт специяльно пиарил С++ через собственические примочки и припарки к С++, лишь бы «держать и не пущать» более нормальные языки программирования: Dylan, Oberon-2, да тот же Common Lisp, наконец.

anonymous
()
Ответ на: комментарий от anonymous

ну да, лучше тот же Ocaml или оксфордская реализация Оберона-2 на Ocaml.

так-то фреймворк компонентный в оберонах оченно гибкий: можно реализовать шину сообщений, смоллток поверх него.

anonymous
()
Ответ на: комментарий от DarkEld3r

Если же используется «интерфейс», то будет обращение через vtable, которая привязана к конкретному указателю, а не самому объекту.

Да, и я уже раз 10 написал о том, что стоимость этого варианта находится в других местах: в дублировании кода (снижается эффективность использование кеша CPU), в дополнительных действиях в момент получения интерфейса из объекта. Сейчас ещё добавлю, что интерфейс - это дополнительная сущность, видимая пользователю, что усложняет прикладной код. Хотя это мы в данном случае не рассматриваем.

den73 ★★★★★
() автор топика
Ответ на: комментарий от anonymous

ЕМНИП, нет. эта функция считается во время компиляции, и VMT Child содержит в себе дополнительно VMT Parent, как примесь (миксин). то есть, ничего дополнительно в рантайме считать не нужно.

Из того, что я прочитал про множественное наследование в С++ (в т.ч. в этой теме не меньше двух ссылок) и из здравого смысла следует, что VMTOffsetOfParent1(Child) может отличаться для разных Child.

Например:

struct child1 : wife1, father;
struct child2 : wife2, father; 
void play(child c) {... }

Если wife1 и wife2 имеют разную толщину, члены отца будут попадать в разные места и не получится вычислить в статике offet vmt, которая в С++ является нулевым членом.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

struct child1 : wife1, father;

Жжош, аффтар. НЕ ОСТАНАВЛИВАЙСЯ!!11

tailgunner ★★★★★
()
Ответ на: комментарий от anonymous

ATL_NO_VTABLE. и как строится «карта сообщений ATL»/

Ну я посмотрел очень быстро и не вникая :) - ничего особенного. То же вычисление смещения во время компиляции, но руками. Видимо, это устаревшая тема. Хотя может быть, я что-то проглядел.

КОП = ООП - наследование реализации

Вот, наконец-то мы дошли до сути дела.

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

Через границы процессов или компьютеров - ещё можно понять. Здесь типы можно представить с помощью proxy (как переводится на Русский)? а физическое размещение данных уже не всегда можно передать (разные архитектуры, безопасность).

наследование — это самый жёсткий тип связи. половина паттернов >проектирования — это переизобретение велосипеда «и рыбку съесть, и >функцию реализовать» : реализовать наследование поведения через >миксин и параметрический полиморфизм.

Я всегда был уверен, что проблема не в наследовании, а в:

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

Вот эти 4 задачи превращают программирование на С++ в головоломку (зачастую просто не имеющую решения), особенно, если верить ранним книжкам. Сами вещи, которые нужно запрограммировать, просты, но на С++ это сделать тяжело, особенно, если нужно вписаться в контекст существующей библиотеки. Это и заставило делать паттерны проектирования - они суть костыли и знаки, очерчивающие места скопления граблей. Проекты типа Qt выбились в люди только тогда, когда вышли за пределы С++. Дополнительная и более мелкая неприятность - отсутствие множественной диспетчеризации, т.е. средствами С++ нельзя даже нормально определить операцию сложения чисел.

Но это так, к слову.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

В общем, существенных аргументов против одиночного наследования реализации я пока не увидел, поэтому такие черты ООП Яра более-менее проясняются:

  • классы мы называем структурами, что соответствует С++
  • атрибуты контроля доступа есть, но их можно нарушить и не будет даже предупреждения.
  • одиночное наследование реализации структуры
  • конструктор предка необязательно вызывать - можно его полностью определить заново в потомке
  • статические методы структур, причём, за счёт совместимости расположения элементов в памяти методы предка зачастую можно вызывать и для потомка.
  • синтаксис для доступа к методу предка из потомка, наподобие предок::метод
  • отсутствует перегрузка (статический выбор тела функции по чису и типам параметров)
  • система описания типов аргументов и переменных позволяет отличить «точно этот класс» и «этот класс или любой его потомок»
  • советы (декораторы для функций)
  • интерфейсы (трейты)

А вот что ещё не решено:

  • родовые функции. Вообще-то они нужны. С другой стороны, сравнение С++ и лиспа показывает, что идея «метод не принадлежит классу» не слишком-то хороша. Взять хотя бы completion в среде. Написал переменную типа класса, нажал Tab - и смотри, какие есть методы. А в лиспе такой возможности нет - Tab показывает вообще все доступные «методы». Т.е. принадлежность метода классу полезна. С другой стороны, + - это всё же родовая функция, а не метод числа. С третьей - нежелательно плодить много конкурирующих сущностей, а у нас уже простые функции, статические, виртуальные методы и родовые функции. Если и делать родовые функции, то явно не такие монструозные как в лиспе. Убрать before, after, around, сделать простой выбор одного конкретного метода, а он пусть может явно вызывать любые другие.
  • виртуальные функции. Имея возможность отличить типы, можно сделать все функции одновременно статическими и виртуальными, если потребовать, чтобы для виртуального вызова нужно было написать что-нибудь вида:
    ребёнок.виртуальный-вызов(играть ())
    
    Полиморфизм выразителен, но зато он сложен. Каждый вызов виртуального метода при изучении кода и при поиске ошибки заставляет просмотреть N ветвей, а не одну. Кара в виде слова «виртуальный вызов» заставит разработчика думать, перед тем, как злоупотреблять.
  • шаблоны. С одной стороны, шаблон - это частный случай макросов и они конкурируют. С другой, в лиспе есть параметрические типы, которые не совсем макросы.
  • делать ли трейты примесями
  • если да, можно ли получить «указатель на примесь». В принципе это в лиспе делается, но для этого примесь нужно агрегировать, а в С++ просто возвращают указатель на какой-то кусок потомка. А если мы агрегировали примесь, то зачем её называть примесью - это уже просто агрегирование.
  • глобальный вопрос - можно многое оптимизировать, но мы уже декларировали возможность горячей модификации класса. При этом часть оптимизаций заведомо теряет смысл.
  • квалификатор eql, т.е. отдельный метод для конкретного экземпляра. Практика показывает, что он полезен, но тогда усложняется структура объекта.
  • тележка для объекта. Если нужно прицепить дополнительные данные к чужому объекту, для этого в лиспе традиционно используются слабые хеш-таблицы, где ключ - это ссылка на объект, а значение - это значение. Но это не так уж эффективно. Можно сделать в любом объекте поле «тележка», в котором хранить произвольные данные. Например, тележка будет массивом массивов, структура которого не зависит от класса (одинакова для любого объекта) а любой вид «дополнительных данных» будет иметь адрес в этом массиве (пару индексов). Тогда за пару разыменований можно будет получить любое дополнительное данное.
den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

в дублировании кода (снижается эффективность использование кеша CPU)

Про дублирование кода не понял. Где оно? Относительно эффективности: и там и там будет обращение по указателю на vtable.

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

fn foo(interface: &T)

let object = S{};
foo(&object);

Где дополнительные действия?

Сейчас ещё добавлю, что интерфейс - это дополнительная сущность, видимая пользователю, что усложняет прикладной код.

Дык, если в интерфейсе возникает необходимость, значит эта сущность уже есть. Случаи когда есть только Object и ExtendedObject, как по мне, вырожденные.

DarkEld3r ★★★★★
()
Ответ на: комментарий от DarkEld3r

Где дополнительные действия?

Снаружи функции foo, если у тебя сначала есть не интерфейс, а объект, его реализующий.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

Снаружи функции foo, если у тебя сначала есть не интерфейс, а объект, его реализующий.

Дык, я показал полный пример. Можно его ещё расширить:

// Типа объект.
struct S {}

// Трейт/интерфейс.
trait T {}

// Реализация интерфейса для нашего объекта.
impl T for S {
}

// Функция принимает интерфейс.
fn foo(interface: &T) {
}

fn main() {
    // Создаём объект.
    let object = S{};
    // Передаём в функцию.
    foo(&object);
}
Никакой разницы с тем, если бы функция принимала ссылку на объект нет. Все «дополнительные действия» происходят автоматически.

DarkEld3r ★★★★★
()
Ответ на: комментарий от DarkEld3r

Я уже несколько раз объяснил со ссылками на документацию. В момент преобразования типа от S к &T нужно создать новый объект:

That is, a trait object like &Foo consists of a ‘data’ pointer and a ‘vtable’ pointer.

Это и есть потеря - в С++ при одиночном наследовании объекта создавать не надо. Потеря у Rust тут небольшая (не нужно производить поиск), но она есть. Далее, в С++ один и тот же машинный код обслуживает S и все его потомки. В Rust, если ты хочешь работать с разными типами, реализующими трейт T, код foo(&object) придётся продублировать столько раз, сколько у тебя типов.

Но это мы ходим по кругу.

den73 ★★★★★
() автор топика
Последнее исправление: den73 (всего исправлений: 1)
Ответ на: комментарий от den73

В момент преобразования типа от S к &T нужно создать новый объект:

Нужно, но это происходит (в большинстве случаев) под капотом. Речь ведь шла о «дополнительных действиях», которые вынужден выполнять программист? Если да, то я привёл пример: ничего «руками» создавать не нужно.

В Rust, если ты хочешь работать с разными типами, реализующими трейт T, код foo(&object) придётся продублировать столько раз, сколько у тебя типов.

Это я снова не понял. Можно разжевать?

DarkEld3r ★★★★★
()
Ответ на: комментарий от DarkEld3r

Речь ведь шла о «дополнительных действиях», которые вынужден выполнять программист?

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

В Rust, если ты хочешь работать с разными типами, реализующими >трейт T, код foo(&object) придётся продублировать столько раз, >сколько у тебя типов.

Допустим функция main - не main, а просто какая-то функция посреди кода. А она принимает какой-то неизвестного типа объект, из него достаёт трейт T и делает с ним что-то. Как это выразить? В С++ можно сказать, что объект, реализующий трейт T - это наследник класса, к-рый мы назовём T. И один машинный код полезет в соответствующую табличку и найдёт там разное.

В Расте, как я понял, можно сделать это только так, чтоб функция not_main была not_main<T>, а это реализуется через шаблоны, т.е. будет много копий одного и того же.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

Т.е. речь шла о лишних действиях машинного кода, а не программиста.

Понятно, значит я не так понял.

В С++ можно сказать, что объект, реализующий трейт T - это наследник класса, к-рый мы назовём T.

Правильно я понимаю, что на плюсах предполагается такое?

class Base {};
class Derived {};

void not_main(Base&);
Если да, то в расте с этим никаких проблем:
trait Base {}
trait Derived : Base {}

struct Object {}

impl Base for Object {}
impl Derived for Object {}

fn not_main(_: &Base) {}

fn main() {
    let derived = Object {};
    not_main(&derived);
}
Если хочется «много копий» (что, кстати, имеет свои преимущества), то надо написать вот так: fn not_main_2<T: Base>(_: &T) {}.

DarkEld3r ★★★★★
()
Ответ на: комментарий от DarkEld3r

Спасибо! Теперь это начинает становиться содержательным. Теперь надо подумать мне и осознать это.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

Осилил прочитать про наследование трейтов в Расте. Примерно понял. В общем, и в расте не отказались от наследования, хотя это наследование трейтов, а не реализации. И из темя Наследование в Rust есть ссылка на обсуждение с предложением добавить аж множественное наследование. Т.е. нельзя сказать, что Rust удалось «устоять» в этом отношении :) Там уже идёт речь и об RTTI. Не глядел целиком, но достаточно понимания, что текущий Rust не представляет из себя законченного дизайна, а значит, он не может являться образцом для копирования ООП.

Подводим итоги темы:

В рамках этой темы я узнал, что при множественном наследовании в С++ объекты конкатенируются, можно получить добротный указатель на предка, и это зачастую будет static_cast.

Остаюсь при мнении, что одиночное наследование реализации полезно, т.к. позволяет уменьшить размер кода.

Осознал полезность типа «в точности этот объект», в то время как в С++ есть только тип «этот объект или его любой наследник».

Узнал, что trait object - это пара из указателя на объект и VMT, но информация о типе содержит только трейт и из неё нельзя получить объект обратно, в то время как в С++ это можно сделать с помощью dynamic_cast. Кроме того, в С++ не нужно создавать новый объект «trait object». Однако, в свете незавершённости Rust, в общем-то это не так важно.

В Scala также есть наследование. Даже в Хаскель что-то подобное протащили, хотя я не осилил.

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

Вот ещё интересная ссылка, там наконец-то написано, что трейты лучше примесей тем, что примеси тоже могут порождать проблему ромба, и что «в Scala ромбовидные иерархии примесей попросту запрещены.» Глубокая и хорошая статья, жаль, что давняя уже и не описан Хаскель.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

текущий Rust не представляет из себя законченного дизайна, а значит, он не может являться образцом для копирования ООП.

Я не то чтобы спорю, но кажется, что «законченный дизайн» продумать не особо легко. И тут мне два варианта видится: или дать гибкие примитивы из которых что угодно собрать можно или продумать базу, которую можно расширять не внося ломающих изменений. В расте пошли по второму пути, время покажет насколько удачно эта самая база была выбрана. И это касается не только ООП.

trait object - это пара из указателя на объект и VMT, но информация о типе содержит только трейт и из неё нельзя получить объект обратно

Без дополнительных телодвижений нельзя, ага.

в С++ есть только тип «этот объект или его любой наследник».

Разве?.. Если у нас есть указатель или ссылка, тогда да. Но если есть значение, то это будет именно «в точности этот объект». Конечно, язык не запрещает присвоить базовому классу наследника (так называемая «срезка»), но в итоге всё равно будет именно базовый класс, пусть и в потенциально несогласованном состоянии.

DarkEld3r ★★★★★
()
Ответ на: комментарий от DarkEld3r

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

Нет, это опасный путь - легко переборщить с гибкостью (пример: Перл).

В расте пошли по второму пути, время покажет насколько удачно эта самая база была выбрана. И это касается не только ООП.

Точно. Но мне как дизайнеру языка интересно опередить время, подсмотрев удачные идеи. Оказывается, в плане ООП раст не подходит, а сначала казалось, что отказ от наследования - это именно «фишка».

Конечно, язык не запрещает присвоить базовому классу наследника (так называемая «срезка»), но в итоге всё равно будет именно базовый класс, пусть и в потенциально несогласованном состоянии.

Ну, я не такой спец по плюсам, но я подозреваю, что виртуальный метод в этом случае будет вызван всё же от наследника. Чтобы стало иначе, компилятору нужно будет как-то особо извратиться. Там вроде есть (ты говорил, кажется) способ вызвать конкретный метод напрямую, но для этого уже пользователь должен предпринять особые телодвижения в каждой точке вызова. Из объявления типа, как я думаю, это не выводится. А могли бы сделать, что если тип = «точно Т», то и виртуальные методы вызываются только от этого типа, а не от потомков. С точки зрения объектной архитектуры это может быть и плохо, зато с т.з. производительности - самое то.

den73 ★★★★★
() автор топика
Последнее исправление: den73 (всего исправлений: 2)
Ответ на: комментарий от den73

Нет, это опасный путь - легко переборщить с гибкостью (пример: Перл).

Или лисп? (:

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

Еее... нет.

#include <iostream>

struct Base {
    virtual void foo() const {
        std::cout << "Base::foo" << std::endl;
    }
};

struct Derived : public Base {
    void foo() const override {
        std::cout << "Derived::foo" << std::endl;
    }
};

void reference(const Base& b) {
    b.foo();
}

void value(Base b) {
    b.foo();
}

int main()
{
  Derived d;
  reference(d);
  value(d);
}
Derived::foo
Base::foo
В том и дело, что если у нас есть значение (T), а не ссылка/указатель (T*/T&), то никакого полиморфизма не будет. Это и есть «точно Т». Другое дело, что передача по значению в сигнатуре функции означает копирование.

А ещё проверку на «полное соответствие» можно изобразить и через static_assert, правда придётся использовать шаблоны.

DarkEld3r ★★★★★
()
Последнее исправление: DarkEld3r (всего исправлений: 2)
Ответ на: комментарий от DarkEld3r

Другое дело, что передача по значению в сигнатуре функции означает копирование.

Да, я тебя недопонял. Но копирование целого объекта не есть эффективный способ избавиться от VMT, поэтому я даже не воспринял твои слова правильно. Можно тогда говорить об «указателе конкретно на тип Base», так что можно во время компиляции исключить поиск в VMT, а обратиться к конкретной реализации, известной для этого типа.

den73 ★★★★★
() автор топика
Ответ на: комментарий от den73

Можно тогда говорить об «указателе конкретно на тип Base», так что можно во время компиляции исключить поиск в VMT, а обратиться к конкретной реализации, известной для этого типа.

Да, такой штуки нет. Ну и я даже не знаю насколько легко и эффективно это сделать можно, даже если оставить в стороне вопрос нужности.

DarkEld3r ★★★★★
()
Ответ на: комментарий от DarkEld3r

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

den73 ★★★★★
() автор топика
Ответ на: комментарий от ddos3

Больше 10 лет прошло. Кое-что поменялось уже.

Смотрю в Itanium C++ABI и реализацию dynamic_cast в libsupc++: всё так же.

Но в любом случае, количество итераций - не больше, чем предков у С, то есть единицы в подавляющем большинстве случаев.

Сравнение type_info включает в себя сравнение строк.

utf8nowhere ★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.