Память и основы управления исполнением

Введение

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

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

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

Память — где все происходит

Программы работают с использованием памяти. Здесь нет никакой магии! Это всего лишь адресуемые ячейки, содержащие упорядоченные байты данных, которые можно преобразовать в числа, литералы, структуры, объекты и т. д. Знание этого порождает ряд вопросов. Прежде всего, как программа работает с ресурсами? Упомянутая ранее ячейка памяти может находиться в двух состояниях. Он может быть пустым, а может быть заполненным. Такая ячейка (точнее их группа) может быть аппроксимирована либо значением, либо переменной. Это означает, что переменная может быть пустой, она может быть «бессодержательной», ничего не содержать. Это также означает, что при создании переменных или значений используется память, чем больше их мы создаем, тем больше используется. Итак, как мы можем поддерживать работу нашей программы в определенных ограниченных рамках? На все эти вопросы можно ответить, рассмотрев механизмы управления памятью на разных языках.

Ссылки и адресация

Переменные и значения — это способ указать на данные, которыми мы хотим манипулировать. В компьютерном программировании говорят, что на адресованные объекты ссылаются, а значения и переменные, выполняющие ссылки, называются ссылками. Существует также такая вещь, как указатели, однако они остаются формой реликвии, вызванной прямым управлением памятью, и по большей части больше не используются. А пока давайте воспользуемся ссылкой в качестве метода для описания того, где в памяти находится содержимое переменной или значения. Обычно значения и переменные ссылаются на полезный контент, однако также можно указывать ни на что, и это называется нулевой ссылкой.

NULL или антимагия

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

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

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

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

Подход 1: неявный нуль

Этот подход утверждает, что концепция null существует, но явно не указана в типе. Таким образом, можно присвоить переменной нулевое содержимое (можно также присвоить значение, хотя такое значение не очень полезно). Однако тип переменной никогда явно не говорит, содержит ли она нуль или нет. Java является примером такого языка.

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

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

Защита от неявного нулевого значения проста и обычно выполняется в форме защитного закрытия. Это шаблон кодирования (не шаблон проектирования, заметьте). Защитное закрытие проверяет определенные условия, когда программа должна завершиться ошибкой. В этом случае является ли объект, над которым работает, нулевым. Ниже приведен простой пример в Котлине:

Теперь интересно то, что в руководстве НАСА по программированию для C указано, что для каждой функции аргументы должны проверяться на наличие нулей (входных данных каждого поведения). Таким образом, результаты неявного null легко увидеть. Во-первых, код становится пронизанным проверками на null, которые не очень удобны для чтения. Java недавно приняла решение искоренить практику с аннотацией @NotNull. Однако на самом деле неявный нуль навсегда оставит в сердце программиста неуверенность в том, не является ли переменная, которую он использует, на самом деле нулевой.

В «Чистом коде» дяди Боба есть множество стратегий, которые касаются исправления неявного нулевого значения. Самые важные из них я нашел здесь:

  • Никогда не возвращайте null (из функции/поведения или метода объекта, никогда, никогда). Их можно заменить пустыми объектами (объектами, которые обозначают, что они не содержат никаких данных, и могут быть соответствующим образом обработаны в логике программы, это будет обсуждаться позже).
  • Не присваивайте значение null какой-либо переменной.
  • Используйте типы контейнеров, чтобы изолировать нулевое значение. Например, Java предоставляет тип Optional<T>, который может упаковывать любой другой тип. Таким образом, вы можете представить его как специальный ящик, который может быть сконфигурирован с любым другим ящиком. Такие типы имеют методы (поведения, назначенные конкретному типу, которые можно использовать), которые позволяют проверить, содержит ли блок что-то или нет. Он не решает ни одну из проблем напрямую, но применяет стиль программирования, при котором создать ошибку, вызывающую нулевое значение, гораздо труднее.
  • Определить обнуляемые значения на этапе проектирования входных данных программы. Это все еще оставляет место для ошибок, но окно для их совершения гораздо уже, чем без такого предварительного планирования.

Подход 2: явное значение null

В этом подходе утверждается, что значение null существует, а тип явно отражает, может ли переменная или значение содержать значение null, иначе ему никогда не будет присвоено значение null. Этот подход более популярен в недавней истории, поскольку он облегчает проблемы, создаваемые неявным значением null. Однако сначала давайте взглянем на пример реализации. Довольно интересно, и, как вы, вероятно, могли предсказать, образец будет в Котлине, поскольку этот язык пошел по просвещенному пути явного нуля.

Как видите, Kotlin предоставляет не только реализацию явного null, но и ряд инструментов для обработки нулевых значений при их использовании. Двумя основными из них являются оператор ?: и функция let. Эти два позволяют сократить схему закрытия защитного кожуха, когда это необходимо. Существует также оператор !!, который напрямую распаковывает значение, допускающее значение NULL, в значение, не допускающее значение NULL. Однако его опасно использовать, потому что если он встретит нуль, он выдаст ошибку. Также злоупотребление им означает плохой стиль кода. Обычная реализация защитного замыкания с использованием if также возможна, хотя и не имеет большого смысла при наличии таких синтаксических структур. Однако синтаксический сахар никогда не должен заменять понимание простых шаблонов, происходящих из более простых языков.

Предложения и советы:

  • Предпочитайте ненулевое значение, а не обнуляемое. Таким образом вы повышаете читабельность и ограничиваете количество переменных в системе.
  • Не возвращайте нулевые значения из функций (поведения). Это разрешено, но не поощряется, поскольку обработка нулевого значения должна быть обеспечена в каждом случае использования, если только не применяются дополнительные шаблоны проектирования (шаблоны проектирования — это распространенные способы решения общих проблем — они будут обсуждаться в следующих главах, не беспокойтесь). .
  • Избегайте явного развертывания значений NULL, в Kotlin это делается с помощью оператора !!. Это чревато ошибками и делает код менее читаемым. Если он не обнуляемый, сделайте его не обнуляемым вместо использования !!.

Подход 3: отсутствие пустых значений

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

Управление памятью

Инструкции программы, так же как значения и переменные, хранятся в памяти. Соответственно, по мере роста системы будет увеличиваться и объем потребляемой памяти. Так что же сделать, чтобы программа не копила память, как сумасшедший отшельник? Вот тут-то и вступает в игру управление памятью, или, скорее, ряд методов, используемых для оптимизации использования памяти. Как правило, в современных языках есть два доступных решения: ручное управление памятью и управление памятью сборщиком мусора. Но прежде чем мы углубимся в суть всего этого, давайте ознакомимся с парой терминов.

Несколько базовых заклинаний (или не очень)

Выделение памяти: это действие по резервированию памяти для выполнения элементов программы. Нехватка памяти приводит к ошибкам, обычно известным как исключения нехватки памяти. Два основных типа этого: переполнение стека и недостаток места в куче.

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

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

Стек: память, выделенная для выполнения данного потока внутри, структурирована по принципу последним пришел – первым вышел (LIFO – последнее, что помещается в стек, первым выходит из него). Обычно он используется для хранения трассировки вызовов различных функций (поведений).

Создание экземпляра и экземпляр: это действие по созданию и выделению памяти для новой переменной или значения. Созданный объект называется экземпляром.

Ссылка: указывает на что-то и присваивает этот указатель значению или переменной. Другими словами, ссылка — это название части памяти. Это не совсем определение программирования, но это самое простое, что я придумал.

Ручное управление памятью

В этом случае за выделение и освобождение памяти отвечает программист. Эта практика была популярна во времена программирования, когда память и процессорное время были дефицитными ресурсами. В настоящее время большинство языков программирования снимают с программиста бремя управления памятью. Ручное управление памятью заставляет программиста различать распределение памяти в куче и стеке и управлять ими соответствующим образом. Таким образом, он подвержен ошибкам, особенно когда с ним справляются менее опытные разработчики. Утечка памяти — это проблема, возникающая, когда память выделяется, но никогда не освобождается. Как видите, это может привести к нехватке ресурсов компьютера. Однако могут возникать и другие ошибки. Обычно они вызваны выделением слишком большого объема памяти либо в стеке, либо в куче.

Как видите, ручное управление памятью чревато ошибками и требует большого опыта, чтобы все было сделано правильно. К счастью, за прошедшие годы был создан ряд инструментов для решения наиболее распространенных проблем. Наиболее популярными являются: детекторы утечек памяти (Leak Canary для Android-приложений, valgrind для программ на C и C++), профилировщики памяти и дампы кучи. Изучение их выходных данных может не только помочь обнаружить и устранить утечки памяти и другие ошибки памяти, но также помочь в оптимизации программ.

Автоматическое управление памятью

Есть шутка, что любой дурак может запрограммировать, когда за него обрабатывается память. Действительно, кто бы это ни говорил, автоматическое управление памятью экономит и время, и жизни (точнее, удлиняет продолжительность жизни разработчиков). В отличие от ручного управления в этом случае компилятор и среда выполнения взаимодействуют, чтобы снять с разработчика бремя управления памятью. Программист просто объявляет и определяет (именует и присваивает) переменные и значения, а исполняющая среда языка делает магию памяти. Однако за это приходится платить (как и за любую магию). Эта стоимость называется Garbage Collector или сокращенно GC.

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

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

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

Выражения и операторы

Прежде чем мы начнем, необходимо небольшое пояснение относительно другого важного элемента. В программировании каждая строка может быть описана либо как выражение, либо как оператор. Выражения возвращают значения, а инструкции — нет. Теперь, когда это объяснено, очень важно понять, почему это на самом деле полезно. Во-первых, это сужает функциональные возможности присваивания, создавая дополнительные ограничения. Kotlin — особенно хороший пример, поскольку там if, when, throw являются ключевыми словами-выражениями. На практике языки с большим количеством выражений уменьшают количество дополнительных переменных, которые необходимо создать. Давайте посмотрим на простой пример.

Первая часть — это оператор if, который в случае Kotlin является выражением, поэтому его можно использовать для присваивания. Функция println выводит строку в консоль, поэтому ничего не возвращает, так как это оператор. Наконец, обратите внимание на //, это обозначает однострочный комментарий. Далее в этом курсе будет целая глава, посвященная комментариям, а пока допустимо знать, что они существуют, и игнорируются при компиляции (сборке программы).

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

Урок истории флоу-контроля или как не надо писать

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

Условные предложения и предикаты

Условие — это просто условие в паре с выполнением действия в случае его выполнения и, опционально, с выполнением действия в случае его невыполнения. Условие — это любое выражение, результатом которого является логическое значение (логическое значение true или false). Это довольно прямое определение, и тем не менее оно показывает, что условное предложение может быть организовано как минимум двумя способами. В большинстве языков это достигается с помощью ключевого слова if, иногда в паре с else. Итак, без лишних слов, давайте, конечно же, посмотрим на пример в Котлине.

Условные выражения позволяют вам разветвлять вашу логику. Таким образом, ваша программа может адаптироваться к вводу и выполнять логику на основе внешних условий. Однако важно не загромождать код вложенными блоками if или несколькими блоками if (блок if — это if плюс действие или if + else + их действия). После того, как мы закончим с функциями и объектами, мы продолжим более подробное обсуждение этого вопроса.

Некоторые языки считаются более чем if роскошью, в которой разработчик не нуждается. Однако большинство предпочло включить инструкцию для составного условного предложения. Обычно такая инструкция называется switch, и она оценивает каждый из своих случаев, прежде чем выбрать первый, который соответствует или полностью проваливается. Детали функционирования составного условного предложения различаются в разных языках, поэтому всегда знакомьтесь с документацией по языку, чтобы избежать ловушек.

Некоторые языки делают switch сверхмощной машиной смерти, которая потенциально может сжечь, изнасиловать и разграбить целую деревню, не обязательно в таком порядке, но вы поняли идею. Kotlin отлично справляется с этой задачей. when — это составное условное выражение Kotlin, обладающее сверхмощностью и в то же время очень безопасное. Я знаю, сейчас это звучит так, будто я рекламирую язык, но Kotlin действительно великолепен. Это звучит еще хуже. Выберите то, что плавает в вашей лодке, просто хорошо изучите его конструкции, прежде чем использовать их. Взгляните на Kotlin, разве это не зверь красоты.

Стоит отметить, что в Kotlin if и when являются выражениями, поэтому они возвращают значения. Это может быть очень полезно при присвоении значений на основе некоторых условий, что в Java гораздо более подробно.

Предикаты

Предикат — это поведение, которое на основе любого ввода возвращает логическое значение (логическое значение true/false). Предикаты полезны по ряду причин. Прежде всего, это проверки, которые можно размещать в нескольких местах вашего кода, которые должны проверять одно и то же. Это гораздо лучшее решение, чем писать одно и то же условие в ваших условных предложениях снова и снова. Во-вторых, это поведение, поэтому предикат также является типом и выполняется как выражение. Другими словами, предикаты можно создавать и заменять на лету, если это необходимо, и благодаря возвращаемым значениям они носят динамический характер. Как правило, хорошей практикой является устранение повторяющихся блоков кода (только, как правило, каждая повторяющаяся строка не позорит ваш дом), и именно здесь предикаты сияют.

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

Петельные петли

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

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

пока

Цикл while связан с условием. Когда условие больше не выполняется, цикл завершается, и выполнение возобновляется после этого цикла. В противном случае он выполняет все, что находится внутри него, пока условие окончательно не станет ложным. Давайте взглянем:

Теперь будьте осторожны, поскольку while склонен к бесконечному циклу. Это происходит, когда условие while всегда истинно. Таким образом, в этом случае цикл будет выполнен 5 раз, а затем завершится, потому что счетчик больше не будет меньше 5.

для

Цикл for немного интереснее и во много раз полезнее. В большинстве современных языков существует два варианта: счетчик для и итеративный для. Все языки реализуют первый, второй немного сложнее. Там, где цикл while зависит от явного условия, циклы for полагаются либо на счетчик, либо на что-то, что можно выполнить (повторить). Другими словами, оба полагаются на ограниченное условие. Обратите внимание, как тема ограничения разработчика снова всплывает на поверхность и как она создает безопасные и полезные инструменты.

счетчик на основе

Эта идея сосредоточена вокруг прилавка. В предыдущем примере мы видели цикл while со счетчиком. Этот вид цикла for интегрирует счетчик в свою структуру. На самом деле счетчик, основанный на цикле for, состоит из четырех частей:

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

Ниже приведен пример на Kotlin для иллюстрации конструкции:

В приведенном выше примере счетчик увеличивается после каждого прохода, и как только он достигает 5, цикл завершается. При каждом проходе цикла счетчик будет печататься. Таким образом, результаты выполнения этого кода будут 0, 1, 2, 3, 4.

В некотором смысле счетчик, основанный на for, является более безопасной, хотя и менее гибкой версией цикла while со счетчиком.

итерация для

По большей части при циклировании мы фактически ожидаем перемещения по списку элементов или записей и выполнения определенных действий с каждым из них. Такое действие называется итерацией. Итерацию можно использовать для перемещения по всему, что определено как итерируемое. Теперь это может различаться между языками. В некоторых случаях это означает, что интерфейс реализован ( Iterable в Java) или оператор перегружен (до чего мы доберемся, так что не волнуйтесь).

Итерация for состоит из двух элементов:

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

Давайте взглянем на пример:

Здесь у нас есть список целых чисел. Цикл берет каждое целое по порядку и выводит его на консоль. Способ использования итерации for в Kotlin — ключевое слово in.

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

Подводя итоги, молодой волшебник, теперь вы знаете, как структурировать повторения и условия в своих заклинаниях, и знаете необходимые слова для описания вашего кода. Таким образом, вы обладаете самыми базовыми знаниями в области кодирования. На этом этапе я бы предложил простое упражнение, также известное как foo-bar.

Создайте простой список, а затем просмотрите его. Для каждого числа, кратного двум, выведите в консоль «foo». Для каждого числа, кратного пяти, выведите «bar». Если вы можете это сделать и это сработает, значит, вы действительно освоили основы и готовы перейти к следующему уроку.

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

Базовые блоки

Далее: Функции