Pull to refresh

«Ровная» объектная модель или чего стоит «синтаксический сахар»

Reading time11 min
Views4.5K
В последние лет 15-20 появилось невероятное количество новых языков программирования. Многие из них представляются как «истинные» объектно-ориентированные языки или, как минимум, языки с поддержкой объектно-ориентированного программирования (ООП). Во всяком случае, особенно подчёркивается то, что на этих языках можно вести разработку, придерживаясь объектно-ориентированной методологии. Чтобы язык программирования являлся ООП-языком, он должен реализовывать некоторую Объектную модель. В связи с тем, что ООП из вида программирования превратилось в нечто, что ближе к концепции и методологии, разные языки стали придерживаться более-менее одной и той же объектной модели. Но, кроме того, эти языки обросли разными синтаксическими конструкциями, часть из которых относится к так называемому «синтаксическому сахару», которые позволяют производить некоторые часто осуществляемые действия над объектами более компактно.
В связи с тем, что в большинстве языков программирования объектная модель сильно усложнена, попробуем определить описание, так скажем, «ровной» Объектной модели, убрав из неё по возможности всё лишнее.

  1. Определён терминальный символ (терм) типа «объект». Выражение «терминальный символ типа „объект“» означает только то, что для этого терма доступны специальные лексические конструкции, выражаемые на уровне языка, мы рассмотрим только две из них — присваивание и вызов метода.
  2. Значением терминального символа «объект» является ссылка на некоторую сущность, описание которой даётся в следующих пунктах.
  3. Определена операция "=" — присваивание по ссылке. Присваивание одного терминального символа (term1) другому (term2) означает копирование ссылки на одну и ту же сущность из терма term1 в term2.
  4. Сущность, которая является значением терма, обладает рядом методов. Вызов метода и передача ему параметров осуществляется с помощью лексических конструкций "." и "()", то есть, например, term.method(param1, param2).
  5. Сущность обладает неупорядоченным набором (других) сущностей, который составляет её состояние.
  6. Существует, по крайней мере, одна сущность, которая позволяет создавать другие сущности с заданным набором методов и с заданным начальным состоянием.
  7. Существует, по крайней мере, одна сущность, которая позволяет копировать состояние и набор методов из одной сущности в другую.
  8. Методы и элементы состояния при копировании (п. 7) не замещаются, если есть такие же с тем же названием в сущности, в которую производится копирование.

Объектной модели, описание которой дано в пунктах 1-8, дадим название «ровная объектная модель».
Вообще говоря, выше определённая модель сильно упрощена, например, даже не рассматривается наследование по умолчанию от базового типа, и даже нет механизма наследования, поддерживаемого в каждом объекте — для этого предполагается наличие одной предопределённой сущности, которая может осуществлять это действие с другими сущностями.
Ровной объектной модели достаточно для того, чтобы назвать язык, который её поддерживает, объектно-ориентированным. Часто упоминаемые при характеристике ООП термины: инкапсуляция, наследование и полиморфизм учтены в пунктах соответственно 4 и 5, 6 и 7, 8. Словом "хак" будем называть лексическую конструкцию языка, которая позволяет осуществлять действие над объектами способом, не описанным в пунктах 1-8. Сразу хочу обратить внимание на то, что средства языка, являющиеся не зависимыми от рассматриваемой объектной модели этого языка и предназначенные в общем случае для других целей, не связанных с манипуляциями над объектами, хаками считать не будем. Например, если рассмотреть реализацию компилятора Objective-C, построенного поверх компилятора C++, то объектная модель C++ не учитывается при рассмотрении хаков в основной объектной модели Objective-C. Сделаем краткую характеристику того, как некоторые языки, в том числе появившиеся не так давно, обросли хаками и насколько эти хаки оказались удачными.

Не надо хаков


Действительно, зачем нужны хаки, когда и так можно красиво и понятно писать программы, и к тому же интерпретатор такого языка получается более простым и быстрым в плане компиляции благодаря тому, что нет дополнительных синтаксических конструкций, которые надо было бы интерпретировать. Ровная объектная модель, рассмотренная выше, предполагает минимум синтаксических конструкций — присваивание и вызов метода — и если сделать чистый объектно-ориентированный язык не включающий других конструкций кроме конструкций для работы с ровной объектной моделью, то он получится очень простой. Очень близок к ровной объектной модели Smalltalk. В нём есть две синтаксические конструкции, указанные для ровной объектной модели — присваивание и вызов метода (посылка сообщения), — но также есть и несколько хаков. Во-первых, есть способы литерального создания специальных объектов — блоков, массивов, символов. Но это очень аккуратные и к тому же полезные хаки, прикопаться не к чему. Другое дело, в Smalltalk есть метамодель. Как ни странно, Smalltalk создавался в расчёте на то, что на нём смогут программировать дети, но в метамодели Smalltalk'а с трудом разберётся даже видавший виды программер. В Smalltalk есть классы, а классы — это явный хак, объектно-ориентированная модель может вполне обойтись без классов. Но для Smalltalk'а это почти не хак, потому что и классы, и метаклассы — это просто глобальные объекты, которые, в частности, решают задачи пунктов 7 и 8, так что хаки Smalltalk'а весьма аккуратные, мягкие и вряд ли у кого-то вызовут нарекания.
Например, нет конструкторов в привычном смысле слова, есть метод new, который наследуется при создании подкласса. Но для наследования тоже нет специальной конструкции, есть просто отправка сообщения subclass тому классу, от которого происходит наследование. А классы — это просто глобальные объекты со всеми вытекающими возможностями:

Object subclass: #Person
instanceVariableNames: 'Family Name '
...


То есть вся сложность управления объектами представлена не на уровне синтаксиса языка, а на уровне метамодели, которая сама по всем правилам вписана в объектную модель языка.
Очень хорошо к ровной объектной модели приближен Self, в нём даже нет операции присваивания, которая заменена на вызов метода (посылку сообщения), так что он в этом смысле даже чище, чем рассмотренная выше ровная объектная модель.

Хаки по необходимости


Старые, давно появившиеся во многих языках программирования хаки — это классы, статические члены, открытые, закрытые, защищённые члены, конструкторы, деструкторы. Это настолько привычные вещи, что мало кто (мягко сказано, «мало кто») отнесёт их к хакам. Но дело в том, что эти вещи были привнесены в программирование, чтобы сделать его даже не столько проще, сколько нагляднее. К тому же в статически типизируемых языках без классов, которые заодно составляют тип объектов, обойтись вообще очень сложно. Хотя, статически типизируемый язык — не идеальная среда для ООП, прямо скажем. В общем, это вполне уместные вещи, удивительно другое — насколько легко они прижились в динамически типизируемых языках. И эта тенденция сохраняется, например, в CoffeeScript. Как хорошо сделали в JavaScript, объект — это хэш-таблица, что и логично, ведь внешне объект — это просто неупорядоченный набор свойств и методов, остаётся только добавить наследование и полиморфизм, что в JavaScript сделано очень удачно. Вообще, прототипобазированный язык очень близок к ровной объектной модели, и JavaScript это хорошо показывает. А вот сделать на почве такого удачного языка как JavaScript другой язык — CoffeeScript, с классами — это уже смахивает на дань традициям, хотя, кому-то такой подход и привычнее.

Ещё несколько мыслей по поводу того, почему классы и статические члены — это хак над объектной моделью. Классы по факту выполняют роль глобальных объектов, которые создают другие объекты. Но при этом классы находятся за рамками объектной модели (C++, Java), хотя есть случаи, когда классы находятся в рамках объектной модели и сами есть объекты (Smalltalk, Python). Получается, что если бы не было классов, можно было бы использовать глобальные объекты для тех же целей. Многие, вероятно сказали бы, что глобальные объекты, как переменные, это плохо. Глобальные переменные — это плохо, но глобальные объекты в языке, где они заменяют классы — нет, ведь нет разницы с классами. Наоборот, с классами иногда всё сложнее, чем с глобальными объектами. Возьмём для примера Java. Чтобы интерпретатор Java «вспомнил», что есть такой класс и проинициализировал его статическую часть, приходится писать Class.forName("** путь до класса"), то есть класс вроде бы должен быть известен среде, а на практике, его ещё надо показать явно, что он есть.
Соответственно, и статические члены можно просто сделать как обычные (нестатические) члены таких глобальных объектов. Но, повторюсь, эти виды хаков вполне нормальны, по крайней мере, это признает абсолютное большинство разработчиков.

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

Очень интересный момент есть в том, как вынужденные ограничения объектной модели одного языка перекочевали в другой. PHP имеет объектную модель, которая синтаксически уж очень похожа на модель Java. Всё бы ничего, но есть одна маленькая разница между ними, Java — статически типизируемый язык, PHP — динамически. Есть такая вещь в PHP, как указание типа передаваемого аргумента в функцию. Но несмотря на то, что в Java и PHP это синтаксически смотрится одинаково, из-за того, что эти языки имеют разный вид типизации, это указание типа аргумента играет совершенно разную роль. В Java — это указание компилятору, как поступать с аргументом при компиляции, а в PHP — это использование Reflection, то есть runtime-проверка того, что аргумент имеет такой-то супертип.

Аккуратные хаки


Конечно, в первую очередь, в категорию аккуратных хаков хочется отнести переопределение/перегрузку операторов. Это уже не вынужденные хаки, а очень удобное расширение объектной модели. Понятно, что все перегрузки операторов можно абсолютно однозначно заменить на вызовы методов, но благодаря их использованию код становится более естественным и выразительным. Эти виды особенностей также можно отнести к удачным расширениям объектной модели.

Мощный хак


Конечно, одним из самых мощных хаков является замыкание. На базе замыкания или без него можно делать каррирование. В общем, функциональное программирование (ФП) перестало быть достоянием высшей касты программистов и пришло в привычные языки. Но всё дело в том, что ФП пришло не просто в популярные языки, а пришло в языки, где уже рулил ООП, а это, в общем-то, направления «немножко» разные. Понятное дело, что ФП не могло подвинуть ООП, поэтому ФП ничего не оставалось делать, как лечь на ООП сверху (в хорошем смысле, просто не знаю, как сказать иначе). Внешне, синтаксически, замыкания выглядят круто, создаётся впечатление, что ФП реально работает «по-честному». Но если посудить здраво, как ФП может работать по-честному, например, в Python, где объекты оккупировали всё от классов до функций и модулей? Правильно, никак, работает не ФП, а ООП, в которое интерпретатором преобразуются все эти обёртки, лямбды, декораторы. То есть получается, что все эти примочки из мира ФП не что иное, как очередные хаки над ООП. Во что они преобразуются? В очень специализированные объекты, например, в C# — в объекты, имеющие супертип Delegate. Эти объекты переопределяют только один метод, в котором, по сути, и реализуется замыкание. А как быть, если не было бы делегатов? Если говорить о C#, там возникла бы небольшая напряжёнка в этом случае, потому что нет нестатических внутренних классов, которые могли бы замыкаться на контекст окружающего объекта, как это возможно в Java. Но до катастрофы дело не дошло бы, можно было бы просто передать ссылку на расширяемый объект при явном создании объекта делегата:

class SomeContext
    {
        public int number
        {
            private set;
            get;
        }

        public SomeContext(int initialNumber)
        {
            number = initialNumber;
        }

        public Addition addition { get { return new Addition(this); } }

        public class Addition
        {
            // можем разместить внутреннее состояние объекта Addition
            public Addition(SomeContext context)
            {
                _contest = context;
            }

            public void add(int number)
            {
                _contest.number += number; // доступ к private-члену, замыкание на контекст расширяемого объекта
            }

            private SomeContext _contest;
        }

    }

    class Program
    {
        static void Main(string[] args)
        {
            SomeContext context = new SomeContext(9);
            SomeContext.Addition addToContext = context.addition;

            // работа делегата с вынесенной логикой
            addToContext.add(6);

            Console.WriteLine(context.number);
        }
    }


Конечно, с использованием делегата, это было бы проще:

class SomeContext
    {
        public int number
        {
            private set;
            get;
        }

        public SomeContext(int initialNumber)
        {
            number = initialNumber;
        }

        public Addition addition { get { return delegate(int what) { number += what; }; } }

        public delegate void Addition(int number);

    }

    class Program
    {
        static void Main(string[] args)
        {
            SomeContext context = new SomeContext(9);
            SomeContext.Addition addToContext = context.addition;

            // external delegate logic
            addToContext(6);

            Console.WriteLine(context.number);
        }
    }


Во обоих случаях видим, что объект, в который выделяется отдельная логика, (объект типа Addition) замыкается на внутренний контекст расширяемого объекта типа SomeContext. А выделяемая логика может быть очень нетривиальная, ради чего обработка этой логики и делегируется другому объекту. Но всё же первое из этих двух решений имеет то преимущество, что в выделяемом объекте может быть сохранено его состояние, если оно есть, а во втором случае создаётся объект типа Delegate без сохранения состояния, то есть между вызовами делегата его текущее состояние не сохранится. Можно было бы записать ещё лаконичнее, используя лямбда-выражения, но сейчас не о том речь. Так что замыкания не такая уж и сильная вещь, хоть и в простых случаях может смотреться очень лаконично.

Хак на хаке


Хаки над ровной объектной моделью могут быть полезны и удобны. Но в некоторых случаях их становится много и создаётся впечатление, что они перестали быть управляемыми. Безусловно, один из самых «хакнутых» языков C#. Кажется, что в этот язык создатели хотели впихнуть всё, что только было возможно, и причём пытались впихнуть не один раз одно и то же. В частности, из-за этого, например, имеются четыре разных способа определить одну и ту же конструкцию обратного вызова. Что-то, похоже, перебор… А ведь все эти конструкции задействуют синтаксис языка и потому перегружают его. Есть также много других странностей, насколько же сложно ребята из Microsoft решили хакнуть объектную модель языка C#:

  1. Два типа объектов — ссылочный и по значению. Явные-неявные переходы (упаковка/распаковка) между ними. Объекты «по значению» крайне не характерны не то чтобы для ровной, но даже для сколько-нибудь привычной объектной модели. И это при том, что все объекты, в том числе, и «по значению», наследуют Object, который ссылочный. Странно, не правда ли?
  2. Невиртуальные методы. Вообще говоря, невиртуальные методы — это отключенный полиморфизм, то есть выход из правил опять же привычной объектной модели. Но это ещё не самое странное. Классы с невиртуальными методами могут реализовывать интерфейсы. То есть на первом уровне, при переходе от интерфейса к реализующему классу с невиртуальными методами полиморфизм само собой работает на этом первом уровне, а потом останавливается.
  3. Полиморфическую связь можно остановить на любом уровне иерархии наследования, объявив методы как new.
  4. Структуры, которые по определению не могут содержать виртуальных функций, могут реализовывать интерфейсы. Со структурами получается вообще очень интересно. Структура — тип-значение, а интерфейс — объектный (ссылочный) тип. Предположим, что структура SomeStructure реализует интерфейс SomeInterface. Есть готовый экземпляр структуры instance, имеющий тип SomeStructure. Объявим переменную SomeInterface anObject = instance. Внимание вопрос: anObject будет ссылаться на экземпляр структуры instance по значению или по ссылке? Оказывается, по значению, хотя интерфейсы имеют объектный (ссылочный) тип. То есть в этом присваивании создаётся копия экземпляра структуры, которая далее существует по ссылке. Если этого не знать заранее, можно где-нибудь «попасться».


Конечно, все эти хаки не являются критичными, по крайней мере, в C# есть более-менее нормальная объектная модель как подмножество хакнутой, и нормальной можно и придерживаться.

Выводы


Навороченный синтаксис не всегда даёт какие-то реально большие возможности в реализации логики приложений. Чаще всего, если не сказать «всегда», всякие «синтаксические сладости» можно заменить на привычные вставки объектов и даже в рамках ровной объектной модели можно писать сложные вещи не приходя при этом к плохо читаемому и плохо структурированному коду. Даже можно сказать больше, имея в распоряжении довольно простую объектную модель, можно изящно и просто справляться со страшными зверями, которых не берут серебряные пули, можно и при этом иметь даже больше свободы в реализации нетривиальной логики, например, как это видно в сравнении двух фрагментов кода на C#, где используется решение на основе нового класса и на основе делегата. Там, где решение на основе класса, можно сохранять состояние между вызовами объекта (делегата), в который вынесена логика.
Другой вывод, который можно сделать, функциональность объектной модели может быть лучше тогда, когда вынесена в метамодель, а не существует на уровне синтаксиса языка. В качестве примера была приведена метамодель Smalltalk. В этом случае мы получаем не меньшую функциональность, но без «захламления» синтаксиса. Но на практике в последнее время наблюдается противоположная тенденция — языки программирования развиваются в направлении наращивания сложности синтаксиса (C#, CoffeScript, Scala), а языки с чистыми объектными моделями мало-помалу уходят в небытие. Например, развитие Self прекращено полностью, а Smalltalk еле жив.
Tags:
Hubs:
Total votes 11: ↑8 and ↓3+5
Comments6

Articles