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


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


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


А как бы хотелось отдать эти проблемы на откуп компилятору! Чтобы компьютер сам автоматически проверял корректность доступа по ссылкам, в том числе и из разных потоков и чтобы все это делалось во время компиляции приложения без накладных расходов в рантайме!


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


Неявные ссылки


Модель передачи аргументов Python не является ни «передачей по значению», ни «передачей по ссылке», а описывается скорее как «передача по ссылке на объект». И в зависимости от типа объекта, который передается в функцию, переменные-аргументы ведут себя по разному.


Поэтому принято считать, что неизменяемые объекты в Python в качестве аргументов передаются по значению, тогда как изменяемые объекты (списки (list), множества (set) и словари (dict)), всегда передаются по ссылке.


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


А вот в С++ есть и указатели (pointer) и ссылки (reference), причем ссылки можно считать синтаксическим сахаром над указателями, которые упрощают чтение и написание кода. К сожалению, одновременно с этим они так же добавляют и путаницы, т.к. при изменении ссылочной переменной в реальности происходит обращение к объекту, на который эта ссылка указывает, но визуально в тексте программы ссылочная переменная ничем не отличаются от остальных переменных "по значению", ведь для работы с ними не требуется выполнять разименовывание указателя.


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


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


Конкурентный доступ


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


Формализация концепции переменных


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


  • Статические — это глобальные переменные, функции и типы данных к которым можно обратиться из любого другого участка кода или модуля. Статические переменные (объекты) создаются как правило в куче и сохраняют свое значение при выходе из блока кода, где были определены (т.е. после выхода из текущей области видимости).
  • Автоматические или локальные переменные, это аргументы функций и переменные в выражениях, которые создаются компилятором в автоматическом режиме как правило на стеке. Локальные и автоматические переменные доступные только изнутри того лексического контекста, в котором они были определены, а их значения уничтожаются при выходе из блока кода, где они были созданы.

И определить следующие виды переменных:


  • переменная по значению (variable by value) — данные хранятся непосредственно в самой переменной, а при копировании переменной создается новая копия данных.
  • общая переменная (common variable) — классическая переменная по ссылке. В переменой хранится только указатель на данные (например shared_ptr) который увеличивает счетчик владений данными. При копировании переменной копируется только указатель и увеличивается счетчик владений. Для такой переменой нельзя получить переменную-ссылку.
  • разделяемая переменная (shared variable) — тоже переменная по ссылке в которой хранится указатель на данные со счетчиком владений, но копировать саму переменную запрещено (можно сделать только swap — обмен значениями), но для такой переменной можно получить переменную-ссылку.
  • переменная ссылка — слабый указатель на разделяемую переменную, который не увеличивает счетчик владений (weak_ptr). Перед использованием слабый указатель необходимо преобразовать в сильный или сохранить в общую переменную.

Тогда правилами для работы с такими видами переменных будут следующие:


  • Переменные по значению и переменные-ссылки могут копироваться из одной переменной в другую аналогичного вида без каких либо ограничений.
  • Общая переменная с сильной ссылкой может быть скопирована только в локальную переменную более низкого уровня или передана в качестве аргумента в функцию. Операция обмена значениями (swap) для общей переменной запрещена.
  • Для разделяемой переменной запрещены операции копирования, но можно выполнять обмен значениями (swap) и можно получать слабую ссылку на разделяемую переменную (точнее на данные в разделяемой переменной).

Ссылки и конкурентный доступ

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


Доступ из разных потоков возможен только у разделяемых переменных. Причем совместный доступ реализуется за счет наличия переменных-ссылок, которые разрешено создавать для разделяемых переменных.


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


Типы ссылок с точки зрения разделяемого доступа могут быть:


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

Операторы для ссылок

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


Захват ссылки — это захват объекта синхронизации доступа к переменной (при его наличии) и преобразование слабой ссылки в сильную с инкрементом счетчика владения и сохранением результата в локальную (автоматическую) переменную.


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


В завершении


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


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


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


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

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


  1. Zenitchik
    08.11.2024 11:19

    Можете пояснить, зачем нужна операция swap, почему нужно запрещать копирование разделяемых ссылок, и что понимается под "использованием ссылки", для которого она непременно должна быть сильной?


    1. rsashka Автор
      08.11.2024 11:19

      Операция swap нужна для реализации copy-and-swap idiom.

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

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


      1. Zenitchik
        08.11.2024 11:19

        Погодите. Допустим, я хочу обратиться к объекту, и прочитать его поле. Мне для этого придётся инкрементировать счётчик владений?


        1. rsashka Автор
          08.11.2024 11:19

          Не вам лично. Если объект является общей переменной (shared_ptr), то его сохранение в локальной переменной будет инкрементировать счетчик владений автоматически.


          1. Zenitchik
            08.11.2024 11:19

            Не вам лично.

            Дураку ясно, что не мне лично! Вы слышали, что про такое слово, как "иносказание"?

            то его сохранение в локальной переменной 

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


            1. rsashka Автор
              08.11.2024 11:19

              Если у вас общая переменная уже содержит shared_ptr, то естественно никакого лишнего инкремента не будет.

              Счетчик владений инкрементируется только при копировании общей переменной в другую переменную или при захвате слабого указателя (преобразовании weak_ptr -> shared_ptr).


  1. ednersky
    08.11.2024 11:19

    Вам надо на ерланг взглянуть, вот где все эти траблемы решены :)


    1. rsashka Автор
      08.11.2024 11:19

      Там и своих проблем хватает.


  1. mayorovp
    08.11.2024 11:19

    Поэтому принято считать, что неизменяемые объекты в Python в качестве аргументов передаются по значению, тогда как изменяемые объекты (списки (list), множества (set) и словари (dict)), всегда передаются по ссылке.

    Чушь. Такой способ передачи параметров называется pass by sharing, русский перевод не устоялся. Он совершенно точно не является передачей по ссылке.


    1. rsashka Автор
      08.11.2024 11:19

      точно не является передачей по ссылке.

      Согласен. Тем более, что в Python и самих ссылок нет. Но это 100% не передача по значению, а как переводить, это уже дело десятое.


      1. Zenitchik
        08.11.2024 11:19

        Это передача по значению. Но значением является ссылка.


        1. rsashka Автор
          08.11.2024 11:19

          В регистрах CPU, как и в ячейках памяти, всегда находятся какие-то значения.
          Речь идет и том, как эти значения интерпретируются на более высоком уровне.


          1. Zenitchik
            08.11.2024 11:19

            Так о том и речь. Я при вызове функции, в списке фактических параметров указал переменную, хранящую ссылку на объект. Эта ссылка честно, как есть, была скопирована и её копия передана в функцию.


            1. rsashka Автор
              08.11.2024 11:19

              Вы совершенно правы с точки зрения классического С/С++!!!

              Однако я пишу немного про другое. В вашем случае компилятор не знает, что это значение ссылка на какую-то переменную (точнее знает, но ему пофиг на циклические ссылки и разделяемый доступ).

              А мне хочется, что бы при полной низкоуровневой совместимости с текущим С++ кодом, компилятор мог делать подсчет ссылок во время компиляции и самостоятельно управлять совместным доступом к переменным, что бы полностью освободить программиста от этих задач.


  1. pawnhearts
    08.11.2024 11:19

    >что с учетом отсутствия в Python строгой типизации

    Python is strongly, dynamically typed.

    • Strong typing means that the type of a value doesn't change in unexpected ways. A string containing only digits doesn't magically become a number, as may happen in Perl. Every change of type requires an explicit conversion.

    • Dynamic typing means that runtime objects (values) have a type, as opposed to static typing where variables have a type.


    1. rsashka Автор
      08.11.2024 11:19

      Спасибо большое за уточнение. Я действительно ошибся с термином, там должно быть "статической"


  1. Apoheliy
    08.11.2024 11:19

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

    какого-нибудь нового языка программирования

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

    Но если мы говорим про язык уровня С++ (а автор Говорит про C++), то можно вспомнить, что в управлении ресурсов участвует не только сама программа (и компилятор за ней), но и другие субъекты.

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

    Таких тонкостей на "низком" уровне (и с памятью, и с хендлами, и с межпоточкой) - их очень много.

    Хочется всё решить новым языком с новыми концепциями? Пожелаю Вам удачи!


    1. rsashka Автор
      08.11.2024 11:19

      Спасибо за комментарий!

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

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