Как и в любой книге по программированию, нам нужно начать с знакомства с языком, включая способы его использования, его основные функции и семантику, а также с рассмотрения некоторых часто используемых шаблонов, которые он использует. Эта часть посвящена именно этому: началу работы с Crystal, но с уклоном в сторону читателей, знающих какой-либо другой язык программирования, но не имевших предыдущего контакта с самим Crystal.
Эта часть содержит следующие главы:
• Глава 1. Введение в Crystal.
• Глава 2. Основы семантики и особенности Crystal.
• Глава 3. Объектно-ориентированное программирование.
Crystal — безопасный, производительный, объектно-ориентированный язык общего назначения. Он был во многом вдохновлен синтаксисом Ruby, а также средами выполнения Go и Erlang, что позволяет программисту быть очень продуктивным и выразительным при создании программ, которые эффективно работают на современных компьютерах.
Crystal имеет надежную систему типов и может компилироваться в собственные программы. Следовательно, большинство ошибок и ошибок в программировании можно обнаружить во время компиляции, что, помимо прочего, обеспечивает нулевую безопасность. Однако наличие типов не означает, что вы должны писать их везде. Crystal использует свою уникальную систему интерференции типов для определения типов практически каждой переменной в программе. Редко встречаются ситуации, когда программисту приходится где-то писать явный тип. Но когда вы это сделаете, вам очень помогут типы-объединения, обобщения и метапрограммирование.
Метапрограммирование — это метод, при котором структурированное представление написанной программы доступно и модифицируется самой программой, создавая новый код. Это место, где Ruby сияет всем своим динамизмом и встроенной моделью отражения, как и Crystal, по-своему. Crystal способен изменять и генерировать код во время компиляции с помощью макросов и модели статического отражения с нулевой стоимостью. Во всех отношениях он напоминает динамический язык, но он компилирует программу в чистый и быстрый машинный код.
Код, написанный на Crystal, выразителен и безопасен, но он также быстр — очень быстр. После создания он конкурирует с другими языками низкого уровня, такими как C, C++ или Rust. Он превосходит практически любой динамический язык, а также некоторые компилируемые языки. Хотя Crystal является языком высокого уровня, он может без дополнительных затрат использовать библиотеки C, лингва-франка системного программирования.
Вы можете использовать Crystal сегодня. После 10 лет интенсивной разработки и тестирования в начале 2021 года была выпущена стабильная и готовая к эксплуатации версия. Наряду с ней доступен полный набор библиотек (называемых «осколками»), включая веб-фреймворки, драйверы баз данных, форматы данных, сетевые протоколы и машинное обучение.
В этой главе будет представлена краткая история языка Crystal и представлены некоторые его характеристики, касающиеся производительности и выразительности. После этого он введет вас в курс дела, объяснив, как создать и запустить вашу первую программу Crystal. Наконец, вы узнаете о некоторых проблемах будущего языка.
В частности, мы затронем следующие темы:
• Немного истории
• Исследование выразительности Crystal
• Программы Crystal также БЫСТРЫ.
• Создание нашей первой программы
• Настройка среды
Это должно помочь вам понять, что такое Crystal, понять, почему его следует использовать, и научиться выполнять свою первую программу. Этот контекст важен для обучения программированию в Crystal, начиная от небольших фрагментов кода и заканчивая полнофункциональными и готовыми к использованию приложениями.
В рамках этой главы вы установите на свой компьютер компилятор Crystal и напишете с его помощью код. Для этого вам понадобится следующее:
• Компьютер Linux, Mac или Windows. В случае компьютера с Windows необходимо включить подсистему Windows для Linux (WSL).
• Текстовый редактор, например Visual Studio Code или Sublime Text. Подойдет любой, но у этих двух есть хорошие готовые к использованию плагины Crystal.
Вы можете получить весь исходный код, используемый в этой главе, из репозитория книги на GitHub по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter01.
Crystal был создан в середине 2011 года в Manas Technology Solutions (https://manas.tech/), аргентинской консалтинговой компании, которая в то время много работала над созданием приложений Ruby on the Rails. Ruby — язык, с которым приятно работать, но его всегда подвергали сомнению из-за недостаточной производительности. Crystal ожил, когда Ари Боренцвейг, Брайан Кардифф и Хуан Вайнерман начали экспериментировать с концепцией нового языка, похожего на Ruby. Это будет статически типизированный, безопасный и компилируемый язык с почти таким же элегантным синтаксисом, как Ruby, но использующий преимущества вывода глобального типа для устранения динамизма во время выполнения. С тех пор многое изменилось, но основные концепции остались прежними.
Результат? Сегодня Crystal — это стабильный и готовый к использованию язык, созданный 10 лет назад, с более чем 500 участниками и растущим сообществом. Команда, стоящая за ним, успешно реализовала язык с быстрой параллельной средой выполнения и уникальной системой вывода типов, которая рассматривает всю программу за один раз, сохраняя при этом лучшие функции Ruby.
Первоначальным мотивирующим фактором для создателей была производительность. Им нравилось программировать на Ruby и использовать обширную экосистему Ruby, но производительности не было. С тех пор Ruby значительно улучшился, но даже сегодня существует ощутимый разрыв по сравнению с другими динамическими языками, такими как Python или JavaScript.
Все началось с простой идеи: что, если бы мы могли иметь ту же выразительность, что и Ruby, определять типы всех переменных и аргументов на основе сайтов вызовов, а затем генерировать собственный машинный код, аналогичный языку C? Они начали прототипировать его как побочный проект в 2011 году, и это сработало. Вначале он был принят как проект «Манас», что позволило троице работать над ним в оплачиваемые часы.
Crystal разрабатывался открыто с самого начала в общедоступном репозитории на GitHub по адресу https://github.com/crystal-lang/crystal. Это привлекло сообщество пользователей, участников, а также спонсоров, которые рассчитывали на успех Crystal. Первоначальный интерес исходил от сообщества Ruby, но вскоре он расширился. На следующем рисунке вы можете увидеть рост числа людей, интересующихся Crystal, измеренный по количеству «звезд» GitHub в основном репозитории.
Рисунок 1.1 – Устойчивый рост звезд GitHub
На момент написания последней версией является 1.2.2, и ее можно установить с официального сайта Crystal по адресу https://crystal-lang.org/.
Много вдохновения пришло от Ruby, но Crystal превратился в другой язык. Он сохранил лучшие части Ruby, но изменил, улучшил и удалил некоторые из его наследий. Ни один из языков не стремится быть совместимым с другим.
Понимание этой истории дает вам возможность проследить, что побудило Crystal создать и развиться до того, чем он является сегодня. Crystal стал очень производительным, но в то же время и очень выразительным. Теперь давайте посмотрим, что придает такую выразительность.
Часто говорят, что Crystal — это язык людей и компьютеров. Это связано с тем, что Crystal стремится к балансу того, чтобы быть удивительно приятным языком для программистов и при этом быть очень производительным для машин. Одно не может существовать без другого, и в Crystal большинство абстракций не приводят к снижению производительности. Он имеет такие особенности и идиомы, как следующие:
• Объектно-ориентированное программирование: все является объектом. Даже сами классы - это объекты, то есть случаи класса. Примитивными типами являются объекты и также имеют методы, и каждый класс может быть вновь открыт и расширен по мере необходимости. Кроме того, Crystal имеет наследование, перегрузку метода/оператора, модули и дженерики.
• Статический тип: все переменные имеют известный тип во время компиляции. Большинство из них выведены компилятором и не написаны программистом. Это означает, что компилятор может улавливать ошибки, такие как вызывные методы, которые не определены или пытаются использовать значение, которое может быть нулевым (или
nil
в Crystal) в то время. Переменные могут быть комбинацией нескольких типов, что позволяет программисту писать динамический код.
• Блоки: Всякий раз, когда вы вызываете метод для объекта, вы можете передать блок кода. Затем этот блок может быть вызван из реализации метода с помощью ключевого слова
yield
. Эта идиома допускает всевозможные итерации и манипуляции с потоком управления и широко распространена среди разработчиков Ruby. Crystal также имеет замыкания, которые можно использовать, когда блоки не подходят друг другу.
• Сбор мусора: Объекты хранятся в куче, и их память автоматически освобождается, когда они больше не используются. Существуют также объекты, созданные из структуры, размещенной во фрейме стека текущего выполняемого метода, и они перестают существовать, как только метод завершается. Таким образом, программисту не приходится иметь дело с ручным управлением памятью.
• Метапрограммирование: Хотя Crystal не является динамическим языком, он часто может вести себя так, как если бы он им был, благодаря мощному метапрограммированию во время компиляции. Программист может использовать макросы и аннотации вместе с информацией обо всех существующих типах (статическое отражение) для генерации или мутирования кода. Это обеспечивает множество динамических идиом и паттернов.
• Одновременное (Concurrent) программирование: Программа Crystal может создавать новые волокна (легкие потоки) для выполнения блокирующего кода, координируясь с каналами. Асинхронное программирование становится простым в рассуждении и следовании. Эта модель была в значительной степени вдохновлена Go и другими параллельными языками, такими как Erlang.
• Кроссплатформенные: программы, созданные с помощью Crystal, могут работать на Linux, MacOS и FreeBSD, нацеливание x86 или ARM (как 32-битный, так и 64-битный). Это включает в себя новые кремниевые чипы от Apple. Поддержка Windows экспериментально, она еще не готова. Компилятор также может производить небольшие статические двоичные файлы на каждой платформе без зависимостей для простоты распространения.
• Безопасность времени выполнения: Crystal является безопасным языком – это означает, что нет неопределенного поведения и скрытых сбоев, таких как доступ к массиву за его пределами, доступ к свойствам по
null
или доступ к объектам после того, как они уже были освобождены. Вместо этого они становятся либо исключениями во время выполнения, ошибками во время компиляции, либо не могут произойти из-за защиты во время выполнения. У программиста есть возможность повысить безопасность, используя явно небезопасные функции языка, когда это необходимо.
• Программирование низкого уровня: хотя Crystal безопасен, всегда есть вариант использовать небезопасные функции. Такие вещи, как работа с необработанными указателями, обращение к нативным библиотекам C или даже использование сборки непосредственно, доступны для смелых. Во многих общих библиотеках C есть безопасные обертки, готовые к использованию, что позволяет им использовать свои функции из кристаллической программы.
На первый взгляд Crystal очень похож на Ruby, и многие синтаксические примитивы одинаковы. Но Crystal пошел своим путем, черпая вдохновение из многих других современных языков, таких как Go, Rust, Julia, Elixir, Erlang, C#, Swift и Python. В результате он сохраняет большую часть хороших частей красивого синтаксиса Ruby, в то же время внося изменения в основные аспекты, такие как метапрограммирование и параллелизм.
С самого начала Crystal создавался как быстрый. Он следует тем же принципам, что и другие быстрые языки, такие как C. Компилятор может анализировать исходный код, чтобы узнать точный тип каждой переменной и расположение памяти перед выполнением. Затем он может создать быстрый и оптимизированный собственный исполняемый файл без необходимости что-либо угадывать во время выполнения. Этот процесс широко известен как предварительная компиляция.
Компилятор Crystal построен на основе LLVM, той же инфраструктуры компилятора, которая используется в Rust, Clang и Apple Swift. В результате Crystal извлекает выгоду из того же уровня оптимизации, что и эти языки, что делает его хорошо подходящим для приложений с интенсивными вычислениями, таких как машинное обучение, обработка изображений или сжатие данных.
Но не все приложения привязаны к процессору. В большинстве случаев на карту поставлены другие ресурсы, такие как сетевые коммуникации или локальный диск. Все вместе они известны как ввод-вывод. Crystal имеет модель параллелизма, аналогичную горутинам Go или процессам Erlang, где несколько операций могут выполняться за циклом событий, не блокируя процесс и не делегируя слишком много работы операционной системе. Эта модель идеально подходит для таких приложений, как веб-службы или инструменты управления файлами.
Использование эффективного языка, такого как Crystal, поможет вам снизить затраты на оборудование и улучшить восприятие реакции пользователей. Кроме того, это означает, что вы можете запускать все меньше и меньше экземпляров вашего приложения для обработки того же объема обработки.
Давайте взглянем на простую реализацию алгоритма сортировки выбором, написанную на Crystal:
def selection_sort(arr)
# Для каждого индекса элемента...
arr.each_index do |i|
# Найдите наименьший элемент после него
min = (i...arr.size).min_by { |j| arr[j] }
# Поменяйте местами позиции с наименьшим элементом
arr[i], arr[min] = arr[min], arr[i]
end
end
# Создайте перевернутый список из 30 тысяч элементов.
list = (1..30000).to_a.reverse
# Отсортируйте его, а затем распечатайте его голову и хвост select_sort(list)
p list[0...10]
p list[-10..-1]
В этом примере уже показаны некоторые интересные особенности Crystal:
• Прежде всего, он относительно небольшой. Основной алгоритм состоит всего из четырех строк.
• Это выразительно. Вы можете перебирать списки со специализированными блоками или использовать диапазоны.
• Не существует единого обозначения типа. Вместо этого компилятор выводит каждый тип, включая аргумент метода.
Удивительно, но этот же код действителен и в Ruby. Воспользовавшись этим, если мы возьмем этот файл и запустим его как Ruby Selection_sort.cr (обратите внимание, что Ruby не заботится о расширениях файлов), это займет около 30 секунд. С другой стороны, выполнение этой программы после ее компиляции с помощью Crystal в оптимизированном режиме занимает около 0,45 секунды, то есть в 60 раз меньше. Конечно, эта разница не одинакова для любой программы. Это зависит от того, с какой рабочей нагрузкой вы имеете дело. Также важно отметить, что Crystal требуется время для анализа, компиляции, при необходимости оптимизации и создания собственного исполняемого файла.
На следующем графике показано сравнение этого алгоритма сортировки выбором, написанного для разных языков. Здесь вы можете видеть, что Crystal соревнуется на вершине, проигрывая C и очень близко приближаясь к Go. Важно отметить, что Crystal — безопасный язык: он имеет полную поддержку обработки исключений, отслеживает границы массивов, чтобы избежать небезопасного доступа, а также проверяет переполнение при целочисленных математических операциях. С другой стороны, C — небезопасный язык, и он ничего из этого не проверяет. Безопасность достигается за счет незначительного снижения производительности, но, несмотря на это, Crystal остается очень конкурентоспособным:
Сортировка выбором в перевернутом списке из 30 тыс. элементов
Рисунок 1.2. Сравнение реализации простой сортировки выбором на разных языках.
Сравнение различных языков и сред выполнения в таких синтетических тестах, как этот, не отражает реальную производительность. Правильное сравнение производительности требует более реалистичной задачи, чем сортировка выбором, и широкого обзора кода экспертами по каждому языку. Тем не менее, разные проблемы могут иметь очень разные характеристики производительности. Итак, рассмотрите возможность сравнительного анализа для вашего варианта использования. В качестве справочного материала для комплексного теста можно изучить тесты TechEmpower Web Framework (https://www.techempower.com/benchmarks).
Crystal не только отлично подходит для выполнения вычислений в небольших случаях, но также хорошо работает в более крупных приложениях, таких как веб-сервисы. Язык включает в себя богатую стандартную библиотеку со всем понемногу, и вы узнаете о некоторых ее компонентах в Главе 4 «Изучение Crystal посредством написания интерфейса командной строки». Например, вы можете создать простой HTTP-сервер, например этот:
require "http/server"
server = HTTP::Server.new do |context|
context.response.content_type = "text/plain"
context.response.print "Hello world, got #{context
.request.path}!"
end
puts "Listening on http://127.0.0.1:8080"
server.listen(8080)
Первая строка
require "http/server”
импортирует зависимость из стандартной библиотеки, которая становится доступной как HTTP::Server
. Затем он создает сервер с некоторым кодом для обработки каждого запроса и запускает его на порту 8080
. Это простой пример, поэтому у него нет маршрутизации.
Давайте сравним это с некоторыми другими языками, чтобы увидеть, насколько хорошо он работает. Но, опять же, это не сложный реальный сценарий, а просто быстрый сравнительный тест:
Запросов в секунду на одном ядре
Рисунок 1.3 – Сравнение скорости запросов в секунду простых HTTP-серверов на разных языках
Здесь мы видим, что Crystal значительно опережает многие другие популярные языки (очень близкие к Rust и Go), а также является очень высокоуровневым и удобным для разработчиков. Многие языки достигают производительности за счет использования низкоуровневого кода, но при этом не требуется жертвовать выразительностью или раскрывать абстракции. Код Crystal легко читать и развивать. Та же тенденция наблюдается и в других приложениях, а не только в веб-серверах или микробенчмарках.
Теперь давайте попрактикуемся в использовании Crystal.
Давайте подготовимся к созданию и запуску приложений Crystal, которые мы начнем в разделе «Создание нашей первой программы». Для этого вам понадобятся две самые важные вещи — текстовый редактор и компилятор Crystal:
• Текстовый редактор. Любой редактор кода справится с этой задачей, но использование редактора с хорошими плагинами для Crystal значительно облегчит жизнь. Рекомендуется использовать Visual Studio Code или Sublime Text. Более подробную информацию о настройке редактора вы можете найти в Приложении А.
• Компилятор Crystal: следуйте инструкциям по установке на веб-сайте Crystal по адресу https://crystal-lang.org/install/.
• После установки текстового редактора и компилятора у вас должна быть работающая установка Crystal! Давайте проверим это: откройте терминал и введите следующее:
crystal eval "puts 1 + 1"
:
Рисунок 1.4 – Вычисление 1 + 1 с помощью Crystal
Эта команда скомпилирует и выполнит код Кристалла
puts 1 + 1
, который запишет результат этого вычисления обратно на консоль. Если вы видите 2, значит, все готово, и мы можем перейти к написанию настоящего кода Crystal.
Теперь давайте поэкспериментируем с созданием нашей первой программы с использованием Crystal. Это основа того, как вы будете писать и выполнять код в оставшейся части этой книги. Вот наш первый пример:
who = "World"
puts "Hello, " + who + "!"
После этого выполните следующие действия:
1. Сохраните это в файле hello.cr.
2. Запустите его с помощью
crystal run hello.cr
на своем терминале. Обратите внимание на результат.
3. Попробуйте изменить переменную
who
на что-нибудь другое и запустить снова.
Не существует шаблонного кода, такого как создание статического класса или «основной» функции. Для этого базового примера также не нужно ничего импортировать из стандартной библиотеки. Вместо этого вы можете просто начать программировать прямо сейчас! Это хорошо для быстрого написания сценариев, но также упрощает приложения.
Обратите внимание: переменную
who
не обязательно объявлять, определять или иметь явный тип. Это все рассчитано для вас.
Вызов метода в Crystal не требует круглых скобок. Вы можете увидеть там
puts
; это просто вызов метода, и его можно было бы записать как puts("Hello, " + who + "!")
.
Конкатенацию строк можно выполнить с помощью оператора
+
. Это всего лишь метод, определенный для строк, и в последующих главах вы узнаете, как определить свой собственный.
Давайте попробуем что-нибудь еще, прочитав имя, введенное пользователем:
def get_name
print "What's your name? "
read_line
end
puts "Hello, " + get_name + "!"
После этого сделаем следующее:
1. Сохраните приведенный выше код в файле с именем “hello_name.cr”.
2. Запустите его с помощью команды
crystal run hello_name.cr
на своем терминале.
3. Он спросит ваше имя; введите его и нажмите Enter.
4. Теперь запустите его еще раз и введите другое имя. Обратите внимание на изменение вывода.
В этом примере вы создали метод
get_name
, который взаимодействует с пользователем для получения имени. Этот метод вызывает два других метода: print
и read_line
. Обратите внимание: поскольку для вызова метода не требуются круглые скобки, вызов метода без аргументов выглядит точно так же, как переменная. Это нормально. Кроме того, метод всегда возвращает свое последнее выражение. В этом случае результат get_name
является результатом read_line
.
Это все еще просто, но позже вы сможете начать писать более сложный код. Здесь вы уже можете увидеть некоторое взаимодействие с консолью и использование методов для повторного использования кода. Далее давайте посмотрим, как из этого кода можно сделать собственный исполняемый файл.
Когда вам нужно отправить приложение на компьютер конечного пользователя или на рабочий сервер, отправлять исходный код напрямую не идеально. Вместо этого лучший подход — скомпилировать код в собственный двоичный исполняемый файл. Они более производительны, их сложнее перепроектировать и они проще в использовании.
До сих пор вы использовали
crystal run hello.cr
для выполнения своих программ. Но у Crystal есть компилятор, и он также должен создавать собственные исполняемые файлы. Это возможно с помощью другой команды; попробуйте crystal build hello.cr
.
Как вы увидите, это не запустит ваш код. Вместо этого он создаст файл «привет» (без расширения), который является собственным исполняемым файлом для вашего компьютера. Вы можете запустить этот исполняемый файл с помощью
./hello.
Фактически,
crystal run hello.cr
работает в основном как сокращение для crystal build hello.cr && ./hello
.
Вы также можете использовать
crystal build --release hello.cr
для создания оптимизированного исполняемого файла. Это займет больше времени, но потребует нескольких преобразований кода, чтобы ваша программа работала быстрее. Более подробную информацию о том, как развернуть окончательную версию вашего приложения, можно найти в Приложении B «Будущее Crystal».
Crystal обеспечивает очень хорошую производительность, стабильность и удобство использования. Это полноценный язык с растущим сообществом и экосистемой, который сегодня можно использовать в производстве. Crystal является очень инновационным и содержит все компоненты успешного языка программирования.
Знание того, как создавать и запускать программы Crystal, будет иметь основополагающее значение в следующих главах, поскольку вам предстоит опробовать множество примеров кода.
Теперь, когда вы знаете о происхождении Crystal и важных характеристиках языка (а именно, о его выразительности и производительности), давайте продолжим изучать основы программирования в Crystal и поможем вам начать и продуктивно работать с этим языком.
В этой главе вы изучите основы программирования Crystal, которые помогут вам быстро освоиться, даже если вы еще не умеете писать ни одной строки кода Crystal. Здесь вы узнаете о вещах, общих для многих других языков программирования, таких как переменные, функции и структуры управления, а также о функциях, характерных для Crystal, таких как система типов и передача блоков. Ожидается, что у вас уже есть базовый опыт работы с каким-либо другим языком программирования.
В этой главе будут рассмотрены следующие основные темы:
• Значения и выражения
• Управление потоком выполнения с помощью условных операторов.
• Изучение системы типов
• Организация кода в методах.
• Контейнеры данных
• Организация кода в файлах.
Для выполнения задач данной главы вам понадобится следующее:
• Рабочая установка Crystal.
• Текстовый редактор, настроенный для использования Crystal.
Вы можете обратиться к Главе 1 «Введение в Crystal» для получения инструкций по настройке Crystal и к Приложению A «Настройка инструментов» для получения инструкций по настройке текстового редактора для Crystal.
Каждый пример в этой главе (а также в остальной части книги) можно запустить, создав текстовый файл с расширением .cr для кода и затем используя команду
crystal file.cr
в терминальном приложении. Вывод или любые ошибки будут показаны на экране.
Вы можете получить весь исходный код, использованный в этой главе, на GitHub книги по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter02.
Программирование — это искусство преобразования и перемещения данных. Мы хотим получать информацию, возможно, от пользователя, печатающего на клавиатуре, от датчика Интернета вещей на крыше вашего дома или даже от входящего сетевого запроса, отправленного на ваш сервер. Затем мы хотим интерпретировать и понять эту информацию, представляя ее в нашей программе структурированным образом. Наконец, мы хотим обработать и преобразовать его, применяя алгоритмы и взаимодействуя с внешними источниками (например, запрос к базе данных или создание локального файла). Практически все компьютерные программы следуют этой структуре, и важно понимать, что все дело в данных.
Crystal имеет множество примитивных типов данных, используемых для выражения значений. Например, вы можете записывать целые числа, используя цифры, например 34. Вы также можете хранить данные в переменных. Они действуют как именованные контейнеры для хранения значений и могут изменяться в любое время. Для этого просто напишите имя переменной, затем символ равенства и значение, которое вы хотите сохранить. Вот пример программы Crystal:
score = 38
distance = 104
score = 41
p score
Вы можете выполнить эту программу Crystal, записав ее в файл и используя
crystal file.cr
на вашем терминале. Если вы это сделаете, вы увидите 41
на экране. Видите эту последнюю строчку? Он использует метод p
для отображения значения переменной на экране.
Если вы работаете с другими языками, такими как Java, C#, Go или C, обратите внимание, что это полноценная программа. В Crystal вам не нужно создавать основную функцию, объявлять переменные или указывать типы. Вместо этого при создании новой переменной и изменении ее значения используется тот же синтаксис.
В одной строке можно присвоить несколько значений нескольким переменным, указав их через запятую. Множественное присвоение обычно используется для замены значений двух переменных. Посмотрите это, например:
# Назначаем две переменные одновременно
emma, josh = 19, 16
# Это то же самое, в две строки
emma = 19
josh = 16
# Теперь поменяем их значения emma, josh = josh, emma
p emma # => 16
p josh # => 19
Этот пример начинается со строки комментария. Комментарии предназначены для добавления пояснений или дополнительных деталей в исходный код и всегда начинаются с символа
#
. Затем у нас есть множественное присваивание, создающее переменные с именами emma
и josh
со значениями 19
и 16
соответственно. Это точно так же, как если бы переменные создавались по одной в две строки. Затем используется другое множественное присвоение для обмена значениями двух переменных, одновременно присваивая emma
значение переменной josh
и josh
значения переменной emma
.
Имена переменных всегда пишутся строчными буквами в соответствии с соглашением о разделении слов символом подчеркивания (известным как snake_case). Хотя это и редкость, в именах переменных также могут использоваться заглавные буквы и неанглийские буквы.
Если используемые вами значения не изменятся, вы можете использовать константы вместо переменных. Они должны начинаться с заглавной буквы и обычно пишутся заглавными буквами, слова разделяются подчеркиванием и не могут быть изменены позже. Посмотрите это, например:
FEET = 0.3048 # Метры
INCHES = 0.0254 # Метры
my_height = 6 * FEET + 2 * INCHES # 1.87960 метров
FEET = 20 # Ошибка: константа FEET уже инициализирована.
Этот код показывает определение двух констант: ФУТОВ и ДЮЙМОВ. В отличие от переменных, им нельзя впоследствии присвоить другое значение. К константам можно получить доступ и использовать их в выражениях вместо их значений. Они полезны при присвоении имен специальным или повторяющимся значениям. Они могут хранить любые данные, а не только числа.
Теперь давайте рассмотрим некоторые из наиболее распространенных примитивных типов данных.
Как и в других языках, числа бывают разных видов; вот таблица с их описанием:
При записи числа в соответствии со значением будет использоваться наиболее подходящий тип: если это целое число, то это будет
Int32
, Int64
или UInt64
, в зависимости от того, что наиболее подходит. Если это значение с плавающей запятой, оно всегда будет Float64
. Вы также можете добавить суффикс, чтобы указать один конкретный тип. Наконец, для улучшения читаемости можно свободно использовать символы подчеркивания. Вот несколько примеров того, как можно выразить числа:
small_number = 47 # Это тип Int32
larger_number = 8795656243 # Теперь это тип Int64
very_compact_number = 47u8 # Тип UInt8 из-за суффикса
other_number = 1_234_000 # Это то же самое, что 1234000
negative_number = -17 # Есть и отрицательные значения
invalid_number = 547_u8 # 547 не соответствует диапазону UInt8
pi = 3.141592653589 # Дробные числа имеют формат Float64
imprecise_pi = 3.14159_f32 # Это Float32
Как и ожидалось, вы также можете выполнять математические операции с числами. В большинстве языков это работает аналогично математике. Операторов много, и их можно переставлять в зависимости от их приоритета. В любом случае круглые скобки могут помочь организовать более крупное выражение. Давайте посмотрим пример:
hero_health_points = 100
hero_defense = 7
enemy_attack = 16
damage = enemy_attack - hero_defense # Враг наносит 9 единиц урона
hero_health_points -= damage # Теперь здоровье героя составляет 91
healing_factor = 0.05 # Герой исцеляется со скоростью 5% за ход
recovered_health = hero_health_points * healing_factor
hero_health_points += recovered_health # Теперь здоровье 95,55
# Этот же расчет можно выполнить и в одну строку: result = (100 - (16 - 7)) * (1 + 0.05) # => 95.55
Вот некоторые из наиболее распространенных операций с числами:
Существуют и другие типы чисел для выражения больших или более точных величин:
•
BigInt
: произвольно большое целое число.
•
BigFloat
: произвольно большие числа с плавающей запятой.
•
BigDecimal
: точные и произвольные числа по основанию 10, особенно полезно для валют.
•
BigRational
: выражает числа в виде числителя и знаменателя.
•
Complex
: содержит число с действительной и мнимой частью.
Все они действуют как числа и имеют функциональность, аналогичную целым числам и числам с плавающей запятой, которые мы уже представили.
В Crystal есть три примитивные константы, каждая из которых имеет свое значение. Ниже указаны типы и использование:
Значения true и false являются результатом выражений сравнения и могут использоваться с условными выражениями. Несколько условных операторов можно комбинировать с помощью
&&
(и) или ||
(или) символы. Например, 3 > 5 || 1 < 2
оценивается как true
.
Не все данные состоят только из чисел; нам часто приходится иметь дело с текстовыми данными. Давайте посмотрим, как мы можем с ними справиться.
Текстовые данные могут быть представлены типом String: они могут хранить произвольные объемы текста UTF-8, предоставляя множество служебных методов для его обработки и преобразования. Существует также тип
Char
, способный хранить одну кодовую точку Юникода: character. Строки выражаются с помощью текста в двойных кавычках, а символы — с одинарными кавычками:
text = "Crystal is cool!"
name = "John"
single_letter = 'X'
kana = 'あ' # Международные символы всегда действительны
Внутри строки вы можете использовать интерполяцию для внедрения в текст других значений. Это полезно для создания строки из данных в других переменных. Хотя вы можете интерполировать любое выражение, постарайтесь сделать его простым. Вот несколько примеров того, как это делается:
name = "John"
age = 37
msg = "#{name} is #{age} years old" # То же, что и "Джону 37 лет"
Вы также можете использовать escape-последовательности внутри строки для обозначения некоторых специальных символов. Например, команда
puts "a\nb\nc”
покажет три строки вывода. Они заключаются в следующем:
Важно помнить, что строки Crystal являются неизменяемыми после их создания, поэтому любая операция над ними приведет к созданию новой строки. Многие операции можно выполнять со строками; они будут использоваться в примерах на протяжении всей книги. Вот некоторые распространенные операции, которые можно выполнять со строками:
Строки и числа являются обычным представлением большинства данных, но есть еще несколько структур, которые мы можем изучить, чтобы облегчить понимание данных.
Еще один полезный тип данных —
Range
; это позволяет представлять интервал значений. Используйте две или три точки, разделяющие значения:
•
a..b
обозначает интервал, начинающийся с a
и заканчивающийся буквой b
включительно.
•
a...b
обозначает интервал, начинающийся с a
и заканчивающийся непосредственно перед b
, исключая его.
Ниже приведены примеры диапазонов:
1..5 # => 1, 2, 3, 4, и 5.
1...5 # => 1, 2, 3, и 4.
1.0...4.0 # => Включает 3,9 и 3,999999, но не 4.
'a'..'z' # => Все буквы алфавита
"aa".."zz" # => Все комбинации двух букв
Вы также можете опустить начало или конец, чтобы создать открытый диапазон. Вот некоторые примеры:
1.. # => Все числа больше 1
...0 # => Отрицательные числа, кроме нуля
.. # => Ассортимент, который включает в себя все, даже самого себя
Диапазоны также можно применять к разным типам; подумайте, например, о временных интервалах.
С диапазонами можно выполнять множество операций. В частности,
Range
реализует как Enumerable
, так и Iterable
, что позволяет ему действовать как сбор данных. Вот несколько служебных методов:
Вы уже можете выражать некоторые данные, используя литеральные значения и переменные в своем коде. Этого достаточно для некоторых базовых вычислений; попробуйте использовать его для некоторых преобразований строк или математических формул. Некоторые виды значений можно сначала объявить, чтобы использовать позже; перечисления — самые простые из них.
Строки используются для представления произвольного текста, обычно касающегося некоторого взаимодействия с пользователем, когда набор всех возможных текстов заранее неизвестен.
String
предлагает операции по разрезанию, интерполяции и преобразованию текста. Бывают случаи, когда значение вообще не предназначено для манипуляций, а просто должно представлять одно состояние из некоторых известных возможностей.
Например, предположим, что вы взаимодействуете с каким-либо пользователем в многопользовательской системе. Этот конкретный пользователь может быть гостем, обычным пользователем, прошедшим проверку подлинности, или администратором. Каждый из них имеет разные возможности, и их следует различать. Это можно сделать с помощью числового кода для представления каждого типа пользователей, например 0, 1 и 2. Или это можно сделать с использованием типа
String
, имеющего типы пользователей «гость», «обычный» и «администратор».
Лучшая альтернатива — объявить правильное перечисление возможных типов пользователей, используя ключевое слово
enum
для создания совершенно нового типа данных. Давайте посмотрим синтаксис:
enum UserKind
Guest
Regular
Admin
end
Переменная, содержащая тип пользователя, может быть назначена путем ссылки на имя типа, а затем на один из объявленных типов:
user_kind = UserKind::Regular
puts "This user is of kind #{user_kind}"
Тип переменной
user_kind
— UserKind
, точно так же, как тип 20 — Int32
. В следующей главе вы узнаете, как создавать более сложные пользовательские типы. Для каждой потребности могут быть созданы разные перечисления; они не будут смешиваться друг с другом.
Значение перечисления можно проверить с помощью метода, сгенерированного из каждой альтернативы. Вы можете использовать
user_kind.guest?
чтобы проверить, содержит ли этот user_kind
тип Guest
или нет. Аналогично, regular?
и admin?
методы можно использовать для проверки других типов.
Объявление и использование перечислений — предпочтительный способ обработки набора известных альтернатив. Например, они позаботятся о том, чтобы вы никогда не ошиблись в написании типа пользователя. В любом случае перечисления — не единственный вариант. Crystal также имеет тип
Symbol
.
Символ подобен программному анонимному перечислению, которое не нужно объявлять. Вы можете просто ссылаться на символы, добавляя двоеточие к имени символа. Они могут выглядеть и ощущаться очень похожими на строки, но их имена не предназначены для проверки и манипулирования ими, как со строками; вместо этого они оптимизированы для сравнения и не могут создаваться динамически:
user_kind = :regular
puts "This user is of kind #{user_kind}"
Символы подобны тегам и однозначно идентифицируются по имени. Сравнение символов более эффективно, чем сравнение строк: они будут совпадать, если их имена совпадают. Чтобы это работало, компилятор просканирует все символы, используемые во всем исходном коде, и объединит символы с одинаковым именем. Их писать быстрее, чем правильное перечисление, но их следует использовать с осторожностью, поскольку компилятор не обнаружит орфографическую ошибку и будет просто рассматриваться как другой символ.
Теперь мы увидели, как выражать множество типов данных, но этого недостаточно. Создание нелинейного кода с помощью условий и циклов имеет фундаментальное значение для более сложных программ, которые должны принимать решения на основе вычислений. Теперь пришло время добавить логику в ваш код.
Crystal, как и большинство императивных языков, имеет построчный поток выполнения сверху вниз. После выполнения текущей строки следующая строка будет следующей. Но вы имеете право контролировать и перенаправлять этот поток выполнения на основе любого условного выражения, которое только можете придумать. Первый вид управления потоком, который мы рассмотрим, — это реакция на условные выражения.
Оператор
if
можно использовать для проверки условия; если оно истинно (то есть не равно nil
и не false
), то оператор внутри него выполняется. Вы можете использовать else
, чтобы добавить действие, если условие неверно. Посмотрите это, например:
secret_number = rand(1..5) # Случайное целое число от 1 до 5
print "Пожалуйста, введите свое предположение:"
guess = read_line.to_i
if guess == secret_number
puts "Вы правильно догадались!"
else
puts "Извините, номер был #{secret_number}."
end
Условное выражение не обязательно должно быть выражением, результатом которого является логическое значение (
true
или false
). Любое значение, кроме ложных, нулевых и нулевых указателей (подробнее об указателях см. в Главе 7, «C Функциональная совместимость»), будет считаться правдивым. Обратите внимание, что нулевые и пустые строки также являются правдивыми.
Противоположностью
if
является unless
. Его можно использовать, когда вы хотите отреагировать, когда условие является false
или nil
. Посмотрите это, например:
unless guess.in? 1..5
puts "Пожалуйста, введите число от 1 до 5."
end
Оператор
if
также может содержать блок else
, но в этом случае всегда лучше изменить порядок на обратный и использовать последовательность if-else
.
И
if
, и unless
можно записать в одну строку, вставив ее после действия. В некоторых случаях это более читабельно. Предыдущий пример аналогичен этому:
puts "Пожалуйста, введите число от 1 до 5." unless guess.in? 1..5
Вы можете объединить несколько операторов
if
, используя один или несколько блоков elsif
. Это уникально для if
и не может использоваться с unless
. Посмотрите это, например:
if !guess.in? 1..5
puts "Пожалуйста, введите число от 1 до 5."
elsif guess == secret_number
puts "Вы правильно угадали!"
else
puts "Извините, номер был #{secret_number}."
end
Как вы часто увидите в Crystal, эти операторы также можно использовать как выражения; они выдадут последний оператор выбранной ветки. Вы даже можете использовать блок
if
в середине присваивания переменной:
msg = if !guess.in? 1..5
"Пожалуйста, введите число от 1 до 5."
elsif guess == secret_number
"Вы правильно угадали!"
else
"Извините, номер был #{secret_number}."
end
puts msg
Это может быть полезно, чтобы избежать повторения или выполнить сложную логику внутри другого выражения. Существует также сокращенная версия, если использовать условие ? истинное утверждение : структура ложного утверждения. Это часто называют тройным:
puts "Вы догадались #{guess == secret_number ? "правильно" : "неправильно"}!"
Часто вы не смотрите на проверку условных операторов, а вместо этого выбираете один из нескольких вариантов. Здесь на помощь приходит оператор
case
, объединяющий длинную последовательность операторов if
.
case
похож на оператор if
, но позволяет определить несколько возможных результатов в зависимости от заданного значения. Вы указываете оператор case
с некоторым значением и одним или несколькими вариантами, проверяющими различные возможности. Вот структура:
case Time.local.month
when 1, 2, 3
puts "Мы в первом квартале"
when 4, 5, 6
puts "Мы во втором квартале"
when 7, 8, 9
puts "Мы в третьем квартале"
when 10, 11, 12
puts "Мы в четвертом квартале"
end
Это прямой эквивалент гораздо более длинной и менее читаемой последовательности операторов
if
:
month = Time.local.month
if month == 1 || month == 2 || month == 3
puts "Мы в первом квартале"
elsif month == 4 || month == 5 || month == 6
puts "Мы во втором квартале"
elsif month == 7 || month == 8 || month == 9
puts "Мы в третьем квартале"
elsif month == 10 || month == 11 || month == 12
puts "Мы в четвертом квартале"
end
Оператор
case
также можно использовать с диапазонами:
case Time.local.month
when 1..3
puts "Мы в первом квартале"
when 4..6
puts "Мы во втором квартале"
when 7..9
puts "Мы в третьем квартале"
when 10..12
puts "Мы в четвертом квартале"
end
Его также можно использовать с типами данных вместо значений или диапазонов:
int_or_string = rand(1..2) == 1 ? 10 : "привет"
case int_or_string
when Int32
puts "Это целое число"
when String
puts "Это строка"
end
Таким образом, интересно использовать оператор
case
для проверки других вещей, кроме прямого равенства. Это работает, потому что за кулисами case использует оператор ===
для сравнения целевого значения с каждым предложением if
. Вместо строгого равенства оператор ===
проверяет равенство или совместимость с заданным набором и является более расслабленным.
Как и оператор
if
, оператор case
также может иметь ветвь else
, если ни один из параметров не соответствует:
case rand(1..10)
when 1..3
puts "Я кот"
when 4..6
puts "Я собака"
else
puts "Я случайное животное"
end
На данный момент вы научились использовать переменные, вызывать методы и выполнять различные операции с условными выражениями. Но также очень полезно повторять выполнение до тех пор, пока какое-либо условие не станет истинным, например, при поиске данных или преобразовании элементов. Теперь вы узнаете о примитивах, позволяющих сделать именно это.
Оператор
while
аналогичен оператору if
, но он повторяется до тех пор, пока условие не станет ложным. Посмотрите это, например:
secret_number = rand(1..5)
print "Пожалуйста, введите ваше предположение: "
guess = read_line.to_i
while guess != secret_number
puts "Извините, это не то. Пожалуйста, попробуйте еще раз: "
guess = read_line.to_i
end
puts "Вы правильно угадали!"
Аналогично, оператор
until
является противоположностью оператора while
, так же, как оператор unless
является противоположностью оператора if
:
secret_number = rand(1..5)
print "Пожалуйста, введите ваше предположение: "
guess = read_line.to_i
until guess == secret_number
puts "Извините, это не то. Пожалуйста, попробуйте еще раз: "
guess = read_line.to_i
end
puts "Вы правильно угадали!"
Внутри циклической структуры вы можете использовать следующие дополнительные ключевые слова:
•
break
— немедленно прерывает цикл и выходит из него без повторной проверки условия.
•
next
— прерывает текущее выполнение цикла и начинает заново с начала, проверяя условие
Вот пример использования
break
и next
для дальнейшего управления потоком:
secret_number = rand(1..5)
while true
print "Пожалуйста, введите свое предположение (ноль, чтобы отказаться): "
guess = read_line.to_i
if guess < 0 || guess > 5
puts "Неверное предположение. Пожалуйста, попробуйте еще раз."
next
end
if guess == 0
puts "Извините, вы сдались. Ответ был #{secret_number}."
break
elsif guess == secret_number
puts "Поздравляем! Вы угадали секретный номер!"
break
end
puts "Извините, это не то. Пожалуйста, попробуйте еще раз."
end
Они составляют основу управления потоком выполнения с использованием условий и структуры цикла. Далее в этой главе вы также узнаете о блоках — наиболее распространенном способе создания циклов в Crystal, особенно с контейнерами данных. Но перед этим давайте углубимся в систему типов.
Crystal — статически типизированный язык; компилятор знает типы каждой переменной и выражения перед выполнением. Это позволяет выполнить несколько проверок правильности вашего кода, например проверить существование вызванных методов и соответствие переданных аргументов сигнатуре или убедиться, что вы не пытаетесь получить доступ к нулевым свойствам.
Одного типа недостаточно в каждой ситуации: одну переменную можно переназначить значениям разных типов, и, таким образом, тип переменной может быть любым из типов каждого значения. Это можно выразить с помощью типа объединения, типа, созданного путем объединения всех возможных типов. Благодаря этому компилятор знает, что переменная может содержать значение любого из этих типов во время выполнения.
Вы можете использовать оператор
typeof(x)
, чтобы определить тип любого выражения или переменной, видимый компилятором. Это может быть объединение нескольких типов. Вы также можете использовать x.class
для определения типа значения во время выполнения; это никогда не будет союзом. Наконец, существует оператор x.is_a?(Type)
, позволяющий проверить, принадлежит ли что-либо заданному типу, что полезно для разветвления и выполнения действий по-разному. Ниже приведены некоторые примеры:
a = 10
p typeof(a) # => Int32
# Измените 'a', чтобы оно стало строкой String
a = "привет"
p typeof(a) # => String
# Возможно, 'a' изменится на Float64
if rand(1..2) == 1
a = 1.5
p typeof(a) # => Float64
end
# Теперь переменная 'a' может быть либо String либо Float64
p typeof(a) # => String | Float64
# Но мы можем узнать во время выполнения, какой это тип.
if a.is_a? String
puts "Это String"
p typeof(a) # => String
else
puts "Это Float64"
p typeof(a) # => Float64
end
# Тип 'a' был отфильтрован внутри условного выражения, но не изменился.
p typeof(a) # => String | Float64
# Вы также можете использовать .class для получения типа среды выполнения
puts "It's a #{a.class}"
В Crystal каждое значение является объектом, даже примитивные типы, такие как целые числа. Объекты имеют тип, и этот тип может реагировать на вызовы методов. Все операции, которые вы выполняете над объектом, проходят через вызов какого-либо метода. Даже
nil
является объектом типа Nil
и может реагировать на методы. Например, nil.inspect
возвращает "nil
".
Все переменные имеют тип или, возможно, объединение нескольких типов. Когда это объединение, оно сохраняет объект одного из типов во время выполнения. Фактический тип можно определить с помощью оператора
is_a?
.
Методы, доступные данному типу, всегда известны компилятору. Таким образом, попытка вызвать несуществующий метод приведет к ошибке времени компиляции, а не к исключению времени выполнения.
К счастью, у Crystal есть инструмент, который помогает нам визуализировать типы по мере их вывода. Следующий раздел проведет вас через это.
Команда
crystal play
запускает Crystal Playground для воспроизведения языка с помощью вашего браузера. Он покажет результат каждой строки вместе с выведенным типом:
1. Откройте терминал и введите "crystal play”; он покажет следующее сообщение:
Listening on http://127.0.0.1:8080
2. Оставьте терминал открытым, а затем запустите этот URL-адрес в своем любимом веб-браузере. Это даст вам удобный интерфейс для начала программирования в Crystal:
Рисунок 2.1 - The Crystal playground
1. С левой стороны у вас есть текстовый редактор с некоторым кодом Crystal. Вы можете попробовать изменить код на какой-нибудь код из этой книги, чтобы получить интерактивный способ обучения.
2. С правой стороны есть поле с некоторыми аннотациями к вашему коду. Например, он покажет вам результат каждой строки рядом с типом значения, видимым компилятором.
Если вы сомневаетесь в каких-то примерах или нестандартных решениях, попробуйте их с помощью Crystal playground.
Переходя к более практичному представлению о том, как используются типы, нам нужно узнать о хранении данных в коллекциях и манипулировании ими. Они всегда печатаются на машинке для обеспечения безопасности.
При написании приложений код необходимо структурировать таким образом, чтобы его можно было повторно использовать, документировать и тестировать. Основой этой структуры является создание методов. В следующей главе мы перейдем к объектно-ориентированному программированию с классами и модулями. Метод имеет имя, может получать параметры и всегда возвращает значение (
nil
также является значением). Посмотрите это, например:
def leap_year?(year) divides_by_4 = (year % 4 == 0)
divides_by_100 = (year % 100 == 0)
divides_by_400 = (year % 400 == 0)
divides_by_4 && !(divides_by_100 && !divides_by_400)
end
puts leap_year? 1900 # => false
puts leap_year? 2000 # => true
puts leap_year? 2020 # => true
Определения методов начинаются с ключевого слова
def
, за которым следует имя метода. В данном случае имя метода — jump_year?
, включая символ вопроса. Затем, если у метода есть параметры, они будут заключены в круглые скобки. Метод всегда возвращает результат своей последней строки, в данном примере — условный результат. Типы не нужно указывать явно, они будут определяться в зависимости от использования.
При вызове метода круглые скобки вокруг аргументов не являются обязательными и часто опускаются для удобства чтения. В этом примере
puts
— это метод, аналогичный jump_year?
и его аргумент является результатом последнего. ставит leap_year? 1900
— это то же самое, что и puts(leap_year?(1900))
.
Имена методов подобны переменным и соответствуют соглашению об использовании только строчных букв, цифр и подчеркиваний. Кроме того, имена методов могут заканчиваться символами вопроса или восклицательного знака. Они не имеют специального значения в языке, но обычно применяются в соответствии со следующим соглашением:
• Метод, заканчивающийся на
?
может указывать на то, что метод проверяет какое-то условие и возвращает значение Bool
. Он также часто используется для методов, которые возвращают объединение некоторого типа и Nil
для обозначения состояния сбоя.
• Метод, заканчивающийся на
!
указывает на то, что выполняемая им операция в некотором роде «опасна», и программисту следует быть осторожным при ее использовании. Иногда может существовать «более безопасный» вариант метода с тем же именем, но без !
символ.
Методы могут основываться на других методах. Посмотрите это, например:
def day_count(year)
leap_year?(year) ? 366 : 365
end
Методы могут быть перегружены по количеству аргументов. Посмотрите это, например:
def day_count(year, month)
case month
when 1, 3, 5, 7, 8, 10, 12
31
when 2
leap_year?(year) ? 29 : 28
else
30
end
end
В этом случае метод будет выбран в зависимости от того, как вы расставите аргументы для его вызова:
puts day_count(2020) # => 366
puts day_count(2021) # => 365
puts day_count(2020, 2) # => 29
Внутри методов ключевое слово return можно использовать для досрочного завершения выполнения метода, при необходимости доставляя значение вызывающему методу. Последнее выражение в теле метода ведет себя как неявный возврат. Чаще всего он используется внутри условий для исключительных путей. Посмотрите это, например:
def day_count(year, month)
if month == 2
return leap_year?(year) ? 29 : 28
end
month.in?(1, 3, 5, 7, 8, 10, 12) ? 31 : 30
end
Поскольку типы могут быть опущены при объявлении метода, типы параметров определяются при вызове метода. Посмотрите это, например:
def add(a, b) # 'a' and 'b' could be anything.
a + b
end
p add(1, 2) # Here they are Int32, prints 3.
p add("Crys", "tal") # Here they are String, prints "Crystal".
# Let's try to cause issues: 'a' is Int32 and 'b' is String.
p add(3, "hi")
# => Error: no overload matches 'Int32#+' with type String
Каждый раз, когда метод вызывается с другим типом, генерируется его специализированная версия. В этом примере один и тот же метод можно использовать для сложения чисел и объединения строк. Его нельзя путать с динамической типизацией: в каждом варианте метода параметр
a
имеет известный тип.
В третьем вызове он пытается вызвать
add
с Int32
и String
. Опять же, для этих типов создается новая специализированная версия add
, но теперь она не будет работать, поскольку a + b
не имеет смысла при смешивании чисел и текста.
Отсутствие указания типов допускает использование шаблона ввода «утка». Говорят, что если оно ходит как утка и крякает как утка, то это, должно быть, утка. В этом контексте, если типы, переданные в качестве аргументов, поддерживают выражение
a + b
, то они будут разрешены, потому что это все, о чем заботится реализация, даже если они относятся к типу, никогда ранее не встречавшемуся. Этот шаблон может быть полезен для предоставления более общих алгоритмов и поддержки неожиданных вариантов использования.
Отсутствие типов — не всегда лучший вариант. Вот несколько преимуществ указания типов:
• Сигнатуру метода с типами легче понять, особенно в документации.
• Для разных типов можно добавлять перегрузки с разными реализациями.
• Если вы допустили ошибку и вызвали какой-либо метод с неправильным типом, сообщение об ошибке будет более четким при вводе параметров.
Crystal имеет специальную семантику для указания типов: можно ограничить типы, которые может принимать параметр. При вызове метода компилятор проверяет, соответствует ли тип аргумента ограничению типа параметра. Если да, то для этого типа будет создана специализированная версия метода. Вот некоторые примеры:
def show(value : String)
puts "The string is '#{value}'"
end
def show(value : Int)
puts "The integer is #{value}"
end
show(12) # => The integer is 12
show("hey") # => The string is 'hey'
show(3.14159) # Error: no overload matches 'show' with type Float64
x = rand(1..2) == 1 ? "hey" : 12
show(x) # => Either "The integer is 12" or "The string is 'hey'"
Параметр можно ограничить типом, написав его после двоеточия. Обратите внимание, что пробел до и после двоеточия обязателен. Типы будут проверяться всякий раз, когда метод вызывается для обеспечения корректности. Если предпринята попытка вызвать метод с недопустимый тип, он будет обнаружен во время компиляции и выдаст правильное сообщение об ошибке.
В этом примере вы также видите тип
Int
. Это объединение всех целочисленных типов и особенно полезен при ограничениях. Вы также можете использовать другие союзы.
Последняя строка показывает концепцию множественной диспетчеризации в Crystal: если аргумент вызова тип объединения (в данном случае
Int32 | String
), и метод имеет несколько перегрузок, компилятор сгенерирует код для проверки фактического типа во время выполнения и выбора правильного реализация метода.
Мультидиспетчеризация также произойдет в иерархии типов, если выражение аргумента имеет абстрактный родительский тип, и для каждого возможного конкретного типа определен метод. В следующей главе вы узнаете больше об определении иерархии типов.
Ограничение типа аналогично аннотациям типов в большинстве других языков, где вы укажите фактический тип параметра. Но в Crystal нет аннотаций типов. Здесь важно слово «ограничение»: ограничение типа служит для ограничения возможных типы приемлемы. Фактический тип по-прежнему исходит из места вызова. Посмотрите это, например:
def show_type(value : Int | String)
puts "Compile-time type is #{typeof(value)}."
puts "Runtime type is #{value.class}."
puts "Value is #{value}."
end
show_type(10)
# => Compile-time type is Int32.
# => Runtime type is Int32.
# => Value is 10.
x = rand(1..2) == 1 ? "hello" : 5_u8
show_type(x)
# => Compile-time type is (String | UInt8).
# => Runtime type is String.
# => Value is hello.
Интересно видеть, что тело метода всегда специализировано для типов, используемых в вызывайте сайт, не требуя проверок во время выполнения или какого-либо динамизма. Это часть того, что делает Кристалл очень быстрый язык.
Вы также можете применить ограничения типа к возвращаемому типу метода; это будет гарантировать, что метод ведет себя так, как ожидалось, и выдает правильные данные. Посмотрите это, например:
def add(a, b) : Int
a + b
end
add 1, 3 # => 4
add "a", "b" # Error: method top-level add must return Int but it is returning String
Здесь вариант строки не удастся скомпилировать, поскольку
a + b
создаст строку, но метод ограничен возвратом Int. Помимо типа, параметры также могут иметь значения по умолчанию.
Методы могут иметь значения по умолчанию для своих аргументов; это способ пометить их как необязательные. Для этого укажите значение после имени параметра, используя символ равенства. Посмотрите это, например:
def random_score(base, max = 10)
base + rand(0..max)
end
p random_score(5) # => Some random number between 5 and 15.
p random_score(5, 5) # => Some random number between 5 and 10.
Вы можете использовать значение по умолчанию, если метод имеет наиболее распространенное значение, но вы все равно хотите, чтобы при необходимости можно было передавать разные значения. Если параметров много со значениями по умолчанию рекомендуется давать им имена.
Когда метод вызывается с множеством аргументов, иногда может быть непонятно, что означает каждый из них. Чтобы улучшить это, параметры могут быть названы в месте вызова. Вот пример:
# These are all the same:
p random_score(5, 5)
p random_score(5, max: 5)
p random_score(base: 5, max: 5)
p random_score(max: 5, base: 5)
Все четыре вызова делают одно и то же, но чем более подробным является вызов, тем легче становится понять, что означает каждый из пяти. Кроме того, вы можете изменить порядок аргументов при использовании именованных параметров.
В некоторых случаях имеет смысл заставить некоторые параметры всегда именоваться. Например, предположим, что у нас есть метод, который возвращает время открытия магазина. Ему необходимо знать, является ли этот день выходным и является ли он частью выходных:
def store_opening_time(is_weekend, is_holiday)
if is_holiday
is weekend ? nil : "8:00"
else
is_weekend ? "12:00" : "9:00"
end
end
В этой реализации нет ничего необычного. Но если вы начнете его использовать, все быстро станет очень запутанным:
p store_opening_time(true, false) # What is 'true' and 'false' here?
You can call the same method while specifying the name of each parameter for clarity:
p store_opening_time(is_weekend: true, is_holiday: false)
Чтобы принудительно дать имена некоторым параметрам, добавьте перед ними символ
*
. Все, что слева от *
, будет позиционными параметрами, а все, что справа, всегда будет именованными параметрами. Они также могут иметь значения по умолчанию:
def store_opening_time(*, is_weekend, is_holiday)
# ...
end
p store_opening_time(is_weekend: true, is_holiday: false)
p store_opening_time(is_weekend: true, is_holiday: false)
p store_opening_time(true, false) # Invalid!
Имейте в виду, что именованные параметры можно использовать всегда, даже если они не являются обязательными.
Иногда параметр может иметь имя, которое имеет большой смысл в качестве описания аргумента для вызывающего объекта, но может звучать странно при использовании в качестве переменной в теле реализации метода. Crystal позволяет вам определить внешнее имя (видимое для вызывающего объекта) и внутреннее имя (видимое для реализации метода). По умолчанию они одинаковы, но это не обязательно. Посмотрите это, например:
def multiply(value, *, by factor, adding term = 0)
value * factor + term
end
p multiply(3, by: 5) # => 15
p multiply(2, by: 3, adding: 10) # => 16
Этот метод принимает два или три параметра. Первый называется значением и является позиционным параметром, то есть его можно вызывать без указания имени. Следующие два параметра названы из-за символа
*
. Второй параметр имеет внешнее имя by
и внутреннее имя фактора. Третий и последний параметр имеет добавление внешнего имени и термин внутреннего имени. Он также имеет значение по умолчанию 0
, поэтому это необязательно. Эту функцию можно использовать для того, чтобы сделать вызов методов с именованными параметрами более естественным.
Методы являются основой для организации и повторного использования кода. Но для дальнейшего улучшения этого метода методы повторного использования также могут получать блоки кода при вызове. Внутри метода вы можете использовать ключевое слово
yield
для вызова полученного блока столько раз, сколько необходимо.
Определить метод, который получает блок, просто; просто используйте выход внутри него. Посмотрите это, например:
def perform_operation
puts "before yield"
yield
puts "between yields"
yield
puts "after both yields"
end
Затем этот метод можно вызвать, передав блок кода либо вокруг
do ... end
, либо в фигурных скобках { ... }
:
perform_operation {
puts "inside block"
}
perform_operation do
puts "inside block"
end
Выполнение этого кода приведет к следующему выводу:
before yield
inside block
between yields
inside block
after both yields
Вы можете видеть, что сообщение внутреннего блока происходит между операторами тела метода. Он появляется дважды, поскольку код внутри блока выполнялся при каждом выходе основного метода.
Но для хорошей работы кода внутри блока может потребоваться некоторый контекст. По этой причине блоки также могут получать аргументы и возвращаемые значения. Вот пример метода, который преобразует элементы массива во что-то другое:
def transform(list)
i = 0
# new_list is an Array made of whatever type the block returns
new_list = [] of typeof(yield list[0])
while i < list.size
new_list << yield list[i]
i += 1
end
new_list
end
numbers = [1, 2, 3, 4, 5]
p transform(numbers) { |n| n ** 2 } # => [1, 4, 9, 16, 25] p transform(numbers) { |n| n.to_s } # => ["1", "2", "3", "4", "5"]
Ключевое слово доходность ведет себя как вызов метода: вы можете передать ему аргументы, и оно вернет результат вызова блока. Параметры блока указываются между парой символов вертикальной черты (
|
), разделенных запятыми, если их несколько.
Вышеупомянутый метод преобразования эквивалентен методу карты, доступному для массивов:
numbers = [1, 2, 3, 4, 5]
p numbers.map { |n| n ** 2 } # => [1, 4, 9, 16, 25]
p numbers.map { |n| n.to_s } # => ["1", "2", "3", "4", "5"]
В Crystal уже определено множество других методов, использующих блоки; наиболее распространенными являются те, которые используются для перебора элементов коллекции данных.
Как и
while
и until
, ключевые слова next
и break
также можно использовать внутри блоков.
next
внутри блока
Используйте
next
, чтобы остановить текущее выполнение блока и вернуться к оператору yield
, который его вызвал. Если значение передается в next
, yield
получит выход. Посмотрите это, например:
def generate
first = yield 1 # This will be 2
second = yield 2 # This will be 10
third = yield 3 # This will be 4
first + second + third
end
result = generate do |x|
if x == 2
next 10
end
x + 1
end
p result
Метод
generate
вызывает полученный блок три раза, а затем вычисляет сумму результатов. Наконец, этот метод вызывается, передавая блок, который может завершиться раньше при следующем вызове. Хорошей аналогией является то, что если бы блоки были методами, ключевое слово yield
действовало бы как вызов метода, а next
было бы эквивалентно return
.
Другой способ выйти из выполнения блока — использовать ключевое слово
break
.
break
внутри блока
Используйте
break
, чтобы остановить метод, вызывающий блок, действуя так, как если бы он вернулся. Расширяя тот же пример, что и раньше, посмотрите на следующее:
result = generate do |x|
if x == 2
break 10 # break instead of next
end
x + 1
end
p result
В этом случае
yield
1
будет равна 2
, но yield
2
никогда не вернется; вместо этого метод generate
будет сразу завершен, а result
получит значение 10
. Ключевое слово break
приводит к завершению метода, вызывающего блок.
Наконец, давайте посмотрим, как ведет себя
return
при использовании внутри блока. Гипотеза Коллатца — это интересная математическая задача, которая предсказывает, что последовательность, в которой следующее значение вдвое превышает предыдущее, если оно четное, или в три раза больше плюс один, если оно нечетное, в конечном итоге всегда достигнет 1
, независимо от того, какое начальное число выбрано.
Следующий метод
collatz_sequence
реализует эту последовательность, бесконечно вызывая блок для каждого элемента. Эта реализация не имеет условия остановки и может либо работать вечно, либо быть завершена раньше вызывающей стороной.
Затем следует реализация метода, который запускает
collatz_sequence
с некоторым начальным значением и подсчитывает, сколько шагов необходимо, чтобы достичь 1
:
def collatz_sequence(n)
while true
n = if n.even?
n // 2
else
3 * n + 1
end
yield n
end
end
def sequence_length(initial)
length = 0
collatz_sequence(initial) do |x|
puts "Element: #{x}"
length += 1
if x == 1
return length # <= Note this 'return'
end
end
end
puts "Length starting from 14 is: #{sequence_length(14)}"
Метод
sequence_length
отслеживает количество шагов и, как только оно достигает 1
, выполняет возврат. В этом случае обратите внимание, что возврат происходит внутри блока метода collatz_sequence
. Ключевое слово return
останавливает вызов блока (например, next
), останавливает метод, который вызвал блок с yield
(например, break
), но затем также останавливает метод, в котором записывается блок. Напоминаем, что return всегда завершает выполнение определения, которое находится внутри.
В этом примере кода выводится
Length starting from 14 is: 17
. Фактически, гипотеза Коллатца утверждает, что этот код всегда найдет решение для любого положительного целого числа. Однако это нерешенная математическая проблема.
Crystal имеет множество встроенных контейнеров данных, которые помогут вам манипулировать и организовывать нетривиальную информацию. Наиболее распространенным на сегодняшний день является массив. Вот краткий обзор наиболее часто используемых контейнеров данных в Crystal:
•
Array
(Массив) — линейный и изменяемый список элементов. Все значения будут иметь один тип, возможно, объединение.
•
Tuple
(Кортеж) — линейный и неизменяемый список элементов, в котором точный тип каждого элемента сохраняется и известен во время компиляции.
•
Set
(Набор) — уникальная и неупорядоченная группа элементов. Значения никогда не повторяются, и при перечислении значения отображаются в том порядке, в котором они были вставлены (без дубликатов).
•
Hash
(Хэш) — уникальная коллекция пар ключ-значение. Значения можно получить по их ключам и перезаписать, обеспечивая уникальность ключей. Как и Set
, он нумеруется в порядке вставки.
•
NamedTuple
— неизменяемая коллекция пар ключ-значение, где каждый ключ известен во время компиляции, а также тип каждого значения.
•
Deque
— изменяемый и упорядоченный список элементов, предназначенный для использования либо в виде структуры стека (FIFO, или First In First Out), либо в качестве структуры очереди (FILO, или First In Last Out). Он оптимизирован для быстрой вставки и удаления на обоих концах.
Далее давайте подробнее рассмотрим некоторые из этих типов контейнеров.
Вы можете выразить некоторые простые данные с помощью чисел и текста, но вам быстро понадобится собрать больше информации в списки. Для этого вы можете использовать массивы и кортежи. Массив — это динамический контейнер, который может увеличиваться, сжиматься и изменяться во время выполнения программы. С другой стороны, кортеж статичен и неизменяем; его размер и типы элементов известны и фиксируются во время компиляции:
numbers = [1, 2, 3, 4] # This is of type Array(Int32)
numbers << 10
puts "The #{numbers.size} numbers are #{numbers}"
# => The 5 numbers are [1, 2, 3, 4, 10]
С массивами нельзя смешивать разные типы, если они не были указаны при создании массива. Эти ошибки обнаруживаются во время сборки; они не являются исключениями во время выполнения. Посмотрите это, например:
numbers << "oops"
# Error: no overload matches 'Array(Int32)#<<' with type String
Используя типы объединения, вы можете иметь массивы, в которых сочетаются более одного типа, либо инициализируя их несколькими типами, либо явно указывая их. Вот пример:
first_list = [1, 2, 3, "abc", 40]
p typeof(first_list) # => Array(Int32 | String)
first_list << "hey!" # Ok
# Now all elements are unions:
element = first_list[0]
p element # => 1
p element.class # => Int32
p typeof(element) # => Int32 | String
# Types can also be explicit:
second_list = [1, 2, 3, 4] of Int32 | String
p typeof(second_list) # => Array(Int32 | String)
second_list << "hey!" # Ok
# When declaring an empty array, an explicit type is mandatory:
empty_list = [] of Int32
Внутри массива все значения имеют один и тот же тип; значения разных типов при необходимости расширяются до объединения типов или общего предка. Это важно, поскольку массивы изменяемы, и значение по заданному индексу можно свободно заменить чем-то другим.
Тип
Array
реализует стандартные модули Indexable
, Enumerable
и Iterable
, предоставляя несколько полезных методов для исследования коллекции и управления ею.
Кортеж похож на массив в том смысле, что он хранит ряд элементов в упорядоченном виде. Два основных различия заключаются в том, что кортежи являются неизменяемыми после их создания и что исходный тип каждого элемента сохраняется без необходимости объединения:
list = {1, 2, "abc", 40}
p typeof(list) # => Tuple(Int32, Int32, String, Int32)
element = list[0]
p typeof(element) # => Int32
list << 10 # Invalid, tuples are immutable.
Поскольку кортежи неизменяемы, они используются не так часто, как массивы.
И массивы, и кортежи имеют несколько полезных методов. Вот некоторые из наиболее распространенных:
Операция | Описание |
list [index] | Считывает элемент по заданному индексу. Вызывает ошибку времени выполнения, если этот индекс выходит за пределы. Если список представляет собой кортеж, а индекс — целое число, ошибка выхода за пределы будет обнаружена во время компиляции. |
list[index]? | Аналогично list [index], но возвращает ni1, если индекс выходит за пределы. |
list.size | Возвращает количество элементов внутри кортежа или массива. |
array[index] = value | Заменяет значение по заданному индексу или повышает, если индекс выходит за пределы. Поскольку кортежи неизменяемы, это доступно только для массивов. |
array << value array.push(value) | Добавляет новое значение в конец массива, увеличивая его размер на единицу. |
array.pop array.pop? | Удаляет и возвращает последний элемент массива. В зависимости от варианта он может поднимать или возвращать ноль в пустых массивах. |
array.shift array.shift? | Аналогично pop, но удаляет и возвращает первый элемент массива, уменьшая его размер на единицу. |
array.unshift(value) | Добавляет новое значение в начало массива, увеличивая его размер на единицу. Это противоположность сдвигу. |
Операция | Описание |
array.sort | Реорганизует элементы массива, чтобы обеспечить их упорядоченность. Другой полезный вариант — сортировка по методу, при которой для получения критериев сортировки требуется блок. Первый вариант возвращает отсортированную копию массива, а второй сортирует на месте. |
array.sort! | |
array.shuffle array.shuffle! | Реорганизует элементы массива случайным образом. Все перестановки имеют одинаковую вероятность. Первый вариант возвращает перетасованную копию массива; второй шаркает на месте. |
list.each do el puts el | Перебирает элементы коллекции. Порядок сохранен. |
end | |
list.find do el | Возвращает первый элемент массива или кортежа, соответствующий заданному условию. Если ни одно не соответствует, возвращается nil. |
el > 3 | |
end | |
list.map do el | Преобразует каждый элемент списка, применяя к нему блок, возвращая новую коллекцию (массив или кортеж) с новыми элементами в том же порядке. У массива также есть карта! метод, который изменяет элементы на месте. |
el + 1 | |
end | |
list.select do el | Возвращает новый массив, отфильтрованный по условию в блоке. Если ни один элемент не соответствует, массив будет пустым. Существует также функция reject, которая выполняет противоположную операцию, фильтруя несовпадающие элементы. Для массивов доступны варианты на месте путем добавления ! к имени метода. |
el > 3 | |
end |
Не все данные упорядочены или последовательны. Для них существуют другие контейнеры данных, например хэш.
Тип Hash представляет собой сопоставление ключей словаря со значениями. Ключи могут иметь любой тип, то же самое касается и значений. Единственное ограничение состоит в том, что каждый ключ может иметь только одно значение, хотя само значение может быть другим контейнером данных, например массивом.
Буквальный хэш создается как список пар ключ-значение внутри фигурных скобок (
{...}
). Ключ отделяется от значения символом =>
. Например, вот самая большая численность населения в мире по странам, по данным Worldometer:
population = {
"China" => 1_439_323_776,
"India" => 1_380_004_385,
"United States" => 331_002_651,
"Indonesia" => 273_523_615,
"Pakistan" => 220_892_340,
"Brazil" => 212_559_417,
"Nigeria" => 206_139_589,
"Bangladesh" => 164_689_383,
"Russia" => 145_934_462,
"Mexico" => 128_932_753,
}
Переменная населения имеет тип
Hash(String, Int32)
и состоит из 10
элементов.
Типы ключей и значений выводятся из использования, но если вам нужно объявить пустой хэш, типы необходимо будет указать явно, как и массивы:
population = {} of String => Int32
Хэши — это изменяемые коллекции, в которых есть несколько операторов для запроса и управления ими. Вот некоторые распространенные примеры:
Операция | Описание |
hash[key] | Считывает значение по заданному ключу. Если ключ не существует, это вызовет ошибку времени выполнения. Например, население ["India"] составляет 1380004385 человек. |
hash[key]? | Считывает значение по заданному ключу, но если ключ не существует, вместо выдачи ошибки возвращается ni 1. Например, население ["India"]? 13 8 00 043 8 5 и население ["Mars"] ? равен nil. |
Hash [key] = value | Заменяет значение данного ключа, если оно существует. В противном случае к хешу добавляется новая пара ключ-значение. |
Операция | Описание |
hash.delete(key) | Находит и удаляет пару, определенную данным ключом. Если он был найден, возвращается удаленное значение; в противном случае возвращается nil. |
hash.each { k, v p k, v } | Перебирает элементы, хранящиеся в хеше. Перечисление следует порядку, в котором были вставлены ключи. Вот пример: |
hash.each key { к | population.each do country, pop puts "#{country} has {pop} |
P к } | people." |
hash.each value { | End |
|v| p v } | |
hash.has key?(key) | Проверяет, существует ли данный ключ или значение в хеш-структуре. |
hash.has value?(val) | |
hash.key for(value) | Находит пару с заданным значением и возвращает ее ключ. Эта операция является дорогостоящей, поскольку ей приходится искать все пары одну за другой. |
hash.key for?(value) | |
hash.keys | Создает массив всех ключей или массив всех значений хеша. |
hash.values |
В качестве интересной задачи давайте посмотрим, как получить общую численность населения всех стран вместе взятых. Мы можем использовать метод значений, чтобы получить массив счетчиков населения, а затем вызвать метод суммы для этого массива, чтобы агрегировать его:
puts "Total population: #{population.values.sum}"
Если вы попробуете этот код, вы увидите, что он не работает со следующим сообщением об ошибке:
Unhandled exception: Arithmetic overflow (OverflowError)
Проблема в том, что популяции — это экземпляр
Hash(String, Int32)
, и поэтому вызов значений в нем приведет к созданию экземпляра Array(Int32)
. Если сложить эти значения, получится 4 503 002 371
, но давайте напомним себе, что экземпляр Int32
может представлять только целые числа от -2 147 483 648
до 2 147 483 647
.
Результат выходит за пределы этого диапазона и не помещается в экземпляр
Int32
. В этих случаях Crystal не выполнит операцию вместо автоматического повышения целочисленного типа или предоставления неверных результатов.
Одним из решений было бы с самого начала хранить счетчики населения как
Int64
, указав тип, как если бы мы делали это с пустым хешем:
population = {
"China" => 1_439_323_776,
"India" => 1_380_004_385,
# ...
"Mexico" => 128_932_753,
} of String => Int64
Другое решение — передать начальное значение методу суммы, используя правильный тип:
puts "Total population: #{population.values.sum(0_i64)}"
Теперь давайте посмотрим, как мы можем перебирать эти коллекции.
При вызове метода можно передать блок кода, разделенный
do...end
. Несколько методов получают блок и работают с ним, многие из них позволяют каким-либо образом выполнять циклы. Первый пример — метод цикла. Это просто — он просто зацикливается навсегда, вызывая переданный блок:
loop do
puts "I execute forever"
end
Это прямой эквивалент использования
while true
:
while true
puts "I execute forever"
end
Два других очень полезных метода, которые берут блоки, — это
times
и each
. Вызов times
для целого числа приведет к повторению блока указанное количество раз, а вызов каждого из коллекции вызовет блок для каждого элемента:
5.times do
puts "Hello!"
end
(10..15).each do |x|
puts "My number is #{x}"
end
["apple", "orange", "banana"].each do |fruit|
puts "Don't forget to buy some #{fruit}s!"
end
В предыдущем примере показано, как можно использовать блоки для обхода некоторой коллекции. При написании кода Crystal это предпочтительнее, чем итерация с помощью цикла
while
. Несколько методов из стандартной библиотеки принимают блок: мы видели каждый, но есть также карта для преобразования каждого элемента во что-то другое, выбора или отклонения для фильтрации элементов на основе некоторого условия и сокращения для вычисления значения на основе каждого элемента.
Очень частым случаем является вызов метода, передающего блок, имеющий только один аргумент, а затем вызов метода для этого аргумента. Например, предположим, что у нас есть массив строк, и мы хотим преобразовать их все в заглавные буквы. Вот три способа написать это:
fruits = ["apple", "orange", "banana"]
# (1) Prints ["APPLE", "ORANGE", "BANANA"]
p(fruits.map do |fruit| fruit.upcase
end)
# (2) Same result, braces syntax
p fruits.map { |fruit| fruit.upcase }
# (3) Same result, short block syntax
p fruits.map &.upcase
В первом фрагменте (1) использовался метод карты вместе с блоком
do... end
. Метод map
выполняет итерацию по массиву, передавая блок для каждого элемента и создавая новый массив с результатом блока. В этом первом примере необходимы круглые скобки, поскольку do...end
блоки подключаются к самому внешнему методу, в данном случае p
.
Второй фрагмент (2) использует синтаксис
{ ... }
и может опускать круглые скобки, поскольку этот блок подключается к ближайшему вызову метода. Обычно синтаксис { ... }
записывается в одну строку, но это не обязательно.
Наконец, мы видим синтаксис коротких блоков в третьем фрагменте (3). Написание
&.foo
аналогично использованию { |x| x.foo }
. Его также можно записать как p fruits.map(&.upcase)
, как если бы блок был общим аргументом вызова метода.
Отличается только синтаксис; поведение и семантика всех трех фрагментов одинаковы. Обычно везде, где это возможно, используется синтаксис коротких блоков.
Контейнер
Tuple
также отображается в определениях методов при использовании параметров splat
.
Метод можно определить так, чтобы он принимал произвольное количество аргументов, используя параметры
splat
. Это делается путем добавления символа *
перед именем параметра: теперь при вызове метода он будет ссылаться на кортеж с нулевым или более значениями аргументов. Посмотрите это, например:
def get_pop(population, *countries)
puts "Requested countries: #{countries}"
countries.map { |country| population[country] }
end
puts get_pop(population, "Indonesia", "China", "United States")
Этот код даст следующий результат:
Requested countries: {"Indonesia", "China", "United States"}
{273523615, 1439323776, 331002651}
Использование splat всегда будет создавать кортежи правильных типов, как если бы метод имел такое количество обычных позиционных параметров. В этом примере
typeof(countries)
будет Tuple(String, String, String)
; тип будет меняться при каждом использовании. Параметры Splat
— наиболее распространенный вариант использования кортежей.
Написание кода в одном файле подходит для некоторых быстрых тестов или очень небольших приложений, но все остальное в конечном итоге придется организовывать в нескольких файлах. Всегда существует основной файл, который вы передаете команде
crystal run
или crystal build
, но этот файл может ссылаться на код в других файлах с ключевым словом require
. Компиляция всегда начинается с анализа этого основного файла, а затем рекурсивного анализа любого файла, на который он ссылается, и так далее.
Разберем пример:
1. Сначала создайте файл с именем Factorial.cr:
def factorial(n)
(1..n).product
end
2. Затем создайте файл с именем program.cr:
require "./factorial"
(1..10).each do |i|
puts "#{i}! = #{factorial(i)}"
end
В этом примере require «./factorial» будет искать файл с именем factorial.cr в той же папке, что и program.cr, и импортируйте все, что он определяет. Невозможно выбрать только часть того, что определяют необходимые файлы; требуют импорта всего последовательно. Запустите этот пример с помощью
crystal run program.cr
.
Один и тот же файл не может быть импортирован дважды; компилятор Crystal проверит и проигнорирует такие попытки.
Вам могут потребоваться файлы двух типов: это либо файл из вашего проекта — в этом случае для ссылки на него используется относительный путь, начинающийся с расширения
. -
или это файл библиотеки, взятый из стандартной библиотеки или из установленной вами зависимости. В этом случае имя используется напрямую, без относительного пути.
Начальный параметр
./
сообщает Crystal искать этот файл в текущем каталоге относительно текущего файла. Он будет искать файл с именем filename.cr или каталог с именем filename, в котором находится файл с именем filename.cr. Вы также можете использовать ../
для ссылки на родительский каталог.
Также поддерживаются шаблоны Glob для импорта всех файлов из заданного каталога, как здесь:
require "./commands/*"
Это импортирует все файлы Crystal в каталог команд. Импорт всего из текущего каталога также допустим:
require
Эта нотация используется в первую очередь для ссылки на файлы из вашего собственного проекта. При ссылке на файлы из установленной библиотеки или стандартной библиотеки Crystal путь не начинается с расширения
..
Если путь не начинается ни с
./
, ни с ../
, это должна быть библиотека. В этом случае компилятор будет искать файл в стандартной библиотеке и в папке lib, куда установлены зависимости проекта. Посмотрите это, например:
require "http/server" # Imports the HTTP server from stdlib.
Server = HTTP::Server.new do |context|
context.response.content_type = "text/plain"
context.response.print "Hello world, got
#{context.request.path}!"
end
puts "Listening on http://127.0.0.1:8080" server.listen(8080)
Для чего-либо большего, чем пара сотен строк, предпочтительнее разделить код и организовать его в файлах, каждый из которых имеет определенную цель или область применения. Таким образом, легче найти ту или иную часть приложения.
В этой главе представлено несколько новых концепций, которые помогут вам приступить к написанию реальных приложений Crystal. Вы узнали об основных типах значений (числа, текст, диапазоны и логические значения), о том, как определять переменные для хранения данных и управления ими, а также о том, как управлять потоком выполнения с помощью условных операторов и циклов. Вы рассмотрели создание методов повторного использования кода различными способами. Наконец, вы узнали о сборе данных с помощью
Array
и Hash
, а также об использовании блоков и параметров splat
. Это набор инструментов, который вы будете использовать до конца книги.
В последующих главах мы начнем применять эти знания в практических проектах. Далее давайте воспользуемся возможностями объектной ориентации Crystal для создания масштабируемого программного обеспечения.
Некоторые языковые детали были опущены, чтобы сделать текст кратким и целенаправленным. Однако вы можете найти документацию и справочные материалы по всему, что здесь объясняется более подробно, на веб-сайте Crystal по адресу https://crystal-lang.org/docs/.
Как и многие другие, Crystal — объектно-ориентированный язык. Таким образом, в нем есть объекты, классы, наследование, полиморфизм и так далее. Эта глава познакомит вас с возможностями Crystal по созданию классов и работе с объектами, а также познакомит вас с этими концепциями. Crystal во многом вдохновлен Ruby, который сам по себе многое заимствует из языка Small Talk, известного своей мощной объектной моделью.
В этой главе мы рассмотрим следующие основные темы:
• Понятие объектов и классов
• Создание собственных классов.
• Работа с модулями
• Значения и ссылки — использование структур.
• Общие классы
• Исключения
Для выполнения задач этой главы вам понадобится следующее:
• Рабочая установка Кристалла.
• Текстовый редактор, настроенный для использования Crystal.
Инструкции по настройке Crystal см. в Главе 1 «Введение в Crystal» и в Приложении A «Настройка инструментов» для инструкций по настройке текстового редактора для Crystal.
Вы можете найти весь исходный код этой главы в репозитории этой книги на GitHub по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter03.
Объекты содержат внутри себя некоторый объем данных и управляют доступом и поведением вокруг этих данных. Они подобны актерам, взаимодействующим с другими объектами посредством вызова методов и обмена данными в строго определенном интерфейсе. Ни одному объекту не разрешается напрямую вмешиваться во внутреннее состояние другого объекта — все взаимодействие определяют методы.
Классы — это чертежи, на основе которых создаются объекты. Каждый объект является экземпляром некоторого класса. Класс определяет структуру данных, доступные методы, поведение и внутреннюю реализацию. Класс объекта часто называют его типом: каждый объект имеет тип.
В Crystal все является объектом: каждое значение, с которым вы взаимодействуете, имеет тип (то есть класс) и методы, которые вы можете вызывать. Числа — это объекты, строки — это объекты — даже
nil
является объектом класса Nil
и имеет методы. Вы можете запросить класс объекта, вызвав для него метод .class
:
p 12.class # => Int32
p "hello".class # => String
p nil.class # => Nil
p true.class # => Bool
p [1, 2, "hey"].class # => Array(Int32 | String)
В предыдущем примере вы можете видеть, что существуют более сложные классы, такие как массив, состоящий из целочисленных и строковых элементов. Не волнуйтесь, мы рассмотрим их в последнем разделе этой главы.
Каждый класс предоставляет некоторые методы объектам, которые являются его экземплярами. Например, все экземпляры класса
String
имеют метод size
, который возвращает количество символов строки в виде объекта типа Int32
. Аналогично, объекты типа Int32
имеют метод с именем +
, который принимает другое число в качестве единственного аргумента и возвращает его сумму, как показано в следующем примере:
p "Crystal".size + 4 # => 11
Это то же самое, что и более явная форма:
p("Crystal".size().+(4)) # => 11
Это показывает, что все распространенные операторы и свойства — это просто вызовы методов.
Некоторые классы не имеют буквального представления, и объекты необходимо создавать непосредственно с использованием имени класса. Ниже приведен пример:
file = File.new("some_file.txt")
puts file.gets_to_end
file.close
Здесь
file
— это объект типа File
, показывающий, как можно открыть файл, прочитать все его содержимое, а затем закрыть его. Новый метод вызывается в File
для создания нового экземпляра класса. Этот метод получает строку в качестве аргумента и возвращает новый объект File
, открывая указанный файл. Отсюда внутренняя реализация этого файла в памяти скрыта и взаимодействовать с ним можно только вызовом других методов. get_to_end
затем используется для получения содержимого файла в виде строки, а метод close используется для закрытия файла и освобождения некоторых ресурсов.
Предыдущий пример можно упростить, используя вариант блока, который автоматически закрывает файл после его использования:
File.openCsome_file.txt") do |file|
puts file.gets_to_end
end
В предыдущем фрагменте методу open передается блок, который получает в качестве аргумента файл (тот же, который возвращает
new
). Блок выполняется, а затем файл закрывается.
Возможно, вы заметили, что так же, как этот код вызывает метод
gets_to_end
объекта file
, он также вызывает метод open класса File
. Ранее вы узнали, что методы — это то, как мы общаемся с объектами, так почему же они используются и для взаимодействия с классом? Это очень важная деталь, о которой следует помнить: в Crystal все является объектами, даже классы. Все классы являются объектами типа Class
, и их можно присваивать переменным точно так же, как простые значения:
p 23.class # => Int32
p Int32.class # => Class
num = 10
type = Int32
p num.class == type # => true
p File.new("some_file.txt") # => #
file_class = File
p file_class.newCsome_file.txt") # => #
Теперь вы знаете, что примитивные значения — это объекты, экземпляры более сложных типов из классов стандартной библиотеки — это объекты, и что сами классы тоже являются объектами. Каждый объект имеет внутреннее состояние и раскрывает методы мышления поведения. Переменные используются для хранения этих объектов.
Хотя Crystal поставляется со многими полезными классами, и вы можете установить больше из внешних зависимостей, вы можете создавать свои собственные классы для всего, что вам нужно. Мы рассмотрим это в следующем разделе.
Классы описывают поведение объектов. Приятно узнать, что стандартные типы, поставляемые с Crystal, по большей части представляют собой обычные классы, которые вы могли бы реализовать самостоятельно. Кроме того, вашему приложению понадобятся еще несколько специализированных классов, поэтому давайте их создадим.
Новые классы создаются с помощью ключевого слова class, за которым следует имя, а затем определение класса. Следующий минимальный пример:
class Person
end
person1 = Person.new
person2 = Person.new
В этом примере создается новый класс с именем
Person
, а затем два экземпляра этого класса — два объекта. Этот класс пуст — он не определяет никаких методов или данных, но классы Crystal по умолчанию имеют некоторую функциональность:
p person1 # You can display any object and inspect it
p person1.to_s # Any object can be transformed into a String
p person1 == person2 # false. By default, compares by reference.
p person1.same?(person2) # Also false, same as above.
p person1.nil? # false, person1 isn't nil.
p person1.is_a?(Person) # true, person1 is an instance of Person.
Внутри класса вы можете определять методы так же, как и методы верхнего уровня. Один из таких методов особенный: метод
initialize
. Он вызывается всякий раз, когда создается новый объект, чтобы инициализировать его в исходное состояние. Данные, хранящиеся внутри объекта, хранятся в переменных экземпляра; они подобны локальным переменным, но они используются всеми методами класса и начинаются с символа @
. Вот более полный класс Person
:
class Person
def initialize(name : String)
@name = name
@age = 0
end
def age_up
@age += 1
end
def name
@name
end
def name=(new_name)
@name = new_name
end
end
Здесь мы создали более реалистичный класс
Person
с внутренним состоянием, состоящим из @name
, String
, @age
и Int32
. В классе есть несколько методов, которые взаимодействуют с этими данными, включая метод initialize
, который создаст нового человека — ребенка.
Теперь давайте воспользуемся этим классом:
jane = Person.new("Jane Doe")
p jane # => #
jane.name = "Mary"
5.times { jane.age_up }
p jane # => #
В этом примере создается экземпляр
Person
путем передачи строки новому методу. Эта строка используется для инициализации объекта и в конечном итоге присваивается переменной экземпляра @name
. По умолчанию объекты можно проверять с помощью метода верхнего уровня p
, который показывает имя класса, адрес в памяти и значение переменных экземпляра. Следующая строка вызывает метод name=(new_name)
— он может делать что угодно, но для удобства он обновляет переменную @name
новым значением. Затем мы вызываем age_up
пять раз и снова проверяем объект. Здесь вы должны увидеть новое имя и возраст человека.
Обратите внимание, что в методе
initialize
мы явно указываем тип аргумента имени вместо того, чтобы позволить компилятору определить его на основе использования. Здесь это необходимо, поскольку типы переменных экземпляра должны быть известны только из класса и не могут быть выведены из использования. Вот почему нельзя сказать, что Crystal имеет механизм вывода глобального типа.
Теперь давайте углубимся в то, как можно определять методы и переменные экземпляра.
Все данные внутри объекта хранятся в переменных экземпляра; их имена всегда начинаются с символа
@
. Существует несколько способов определить переменную экземпляра для класса, но одно правило является фундаментальным: их тип должен быть известен. Тип может быть либо указан явно, либо синтаксически выведен компилятором.
Начальное значение переменной экземпляра может быть задано либо внутри метода
initialize
, либо непосредственно в теле класса. В последнем случае он ведет себя так, как если бы переменная была инициализирована в начале метода initialize
. Если переменная экземпляра не назначена ни в одном методе initialize
, ей неявно присваивается значение nil
.
Тип переменной будет определяться из каждого присвоения ей в классе, из всех методов. Но имейте в виду, что их тип может зависеть только от литеральных значений или типизированных аргументов и больше ни от чего. Давайте посмотрим несколько примеров:
class Point
def initialize(@x : Int32, @y : Int32)
end
end
origin = Point.new(0, 0)
В этом первом случае класс
Point
указывает, что его объекты имеют две целочисленные переменные экземпляра. Метод initialize
будет использовать свои аргументы, чтобы предоставить им начальное значение:
class Cat
@birthday = Time.local
def adopt(name : String)
@name = name
end
end
my_cat = Cat.new
my_cat.adopt("Tom")
Теперь у нас есть класс, описывающий кошку. У него нет метода
initialize
, поэтому он ведет себя так, как если бы он был пустым. Переменная @birthday
назначается Time.local
. Это происходит внутри этого пустого метода initialize
при создании нового экземпляра объекта. Предполагается, что тип является экземпляром Time
, поскольку Time.local
вводится так, чтобы всегда возвращать его. Переменная @name
получает строковое значение из типизированного аргумента, но нигде не имеет начального значения, поэтому ее тип — String?
(это также можно представить как String | Nil
).
Обратите внимание, что выведение переменной экземпляра из аргумента работает только в том случае, если параметр указан явно, а переменной экземпляра присваивается непосредственно значение. Следующий пример недействителен:
class Person
def initialize(first_name, last_name)
@name = first_name + " " + last_name
end
end
person = Person.new("John", "Doe")
В этом примере переменная
@name
создается путем объединения двух аргументов с пробелами между ними. Здесь тип этой переменной невозможно определить без более глубокого анализа типов двух параметров и результата вызова метода +
. Даже если бы аргументы были явно типизированы как String
, информации все равно было бы недостаточно, поскольку метод +
для строк может быть переопределен где-то в коде, чтобы возвращать какой-либо другой произвольный тип. В подобных случаях необходимо объявить тип переменной экземпляра:
class Person
@name : String
def initialize(first_name, last_name)
@name = first_name + " " + last_name
end
end
В качестве альтернативы можно использовать буквальную интерполяцию строки, поскольку она гарантированно всегда создает строку:
class Person
def initialize(first_name, last_name)
@name = "#{first_name} #{last_name}"
end
end
В любой ситуации допускается явное объявление типа переменной экземпляра, возможно, для ясности.
Вы можете задаться вопросом, почему компилятор не анализирует всю программу и каждый вызов метода, чтобы самостоятельно определить типы каждой переменной экземпляра, как он уже делает это для локальных переменных? Компилятор делал именно это в первые дни, но эта функция была удалена, поскольку этот анализ был слишком дорогим с точки зрения производительности и делал инкрементальную компиляцию невозможной в будущем. Существующие правила вывода переменных экземпляра в большинстве случаев успешны, и их редко приходится вводить.
Переменные экземпляра представляют частное состояние объекта, и ими следует манипулировать только с помощью методов внутри класса. Их можно раскрыть через геттеры и сеттеры. Доступ к переменным экземпляра можно получить извне с помощью синтаксиса
obj.@ivar
, но это не рекомендуется.
В Crystal нет специальной концепции метода получения или установки свойств объекта; вместо этого они построены на основе функций, о которых мы уже узнали. Допустим, у нас есть человек, у которого есть переменная экземпляра имени:
class Person
def initialize(@name : String)
end
end
Мы уже можем создать нового человека и проверить его:
person = Person.new("Tony")
p person
Но было бы неплохо иметь возможность написать что-то вроде следующего, как если бы
@name
был доступен:
puts "My name is #{person.name}"
person.name
— это просто вызов метода name объекта person
. Помните, что круглые скобки необязательны для вызовов методов. Мы можем пойти дальше и создать именно этот метод:
class Person
def name
@name
end
end
Теперь вызов person.name действителен, как если бы переменная экземпляра была доступна извне. В качестве дополнительного преимущества будущий рефакторинг может изменить внутреннюю структуру объекта и переопределить этот метод, не затрагивая пользователей. Это настолько распространено, что специально для этого существует служебный макрос:
class Person
getter name
end
Предыдущие два фрагмента ведут себя одинаково. Макрос-получатель создает метод, предоставляющий переменную экземпляра. Его также можно комбинировать с объявлением типа или начальным значением:
class Person
getter name : String
getter age = 0
getter height : Float64 = 1.65
end
Несколько геттеров могут быть созданы в одной строке:
class Person
getter name : String, age = 0, height : Float64 = 1.65
end
Для сеттеров логика очень похожа. Имена методов Crystal могут заканчиваться символом
=
для обозначения установщика. Если у него один параметр, его можно вызвать с помощью удобного синтаксиса:
class Person
def name=(new_name)
puts "The new name is #{new_name}"
end
end
Этот метод
name=
можно вызвать следующим образом:
person = Person.new("Tony")
person.name = "Alfred"
Последняя строка представляет собой просто вызов метода и не меняет значение переменной экземпляра
@name
. Это то же самое, что написать person.name=("Alfred")
, как если бы =
была любая другая буква. Мы можем воспользоваться этим, чтобы написать метод установки:
class Person
def name=(new_name)
@name = new_name
end
end
Теперь он будет вести себя так, как если бы имя было общедоступным свойством объекта. Макрос установки может создавать для вас эти методы, подобно макросу геттера, который мы только что видели:
class Person
setter name
end
Его также можно использовать с объявлением типа или начальным значением.
Нам часто необходимо предоставить переменную экземпляра как с помощью геттера, так и сеттера. Для этого у Crystal есть макрос свойств:
class Person
property name
end
Это то же самое, что написать следующее:
class Person
def name
@name
end
def name=(new_name)
@name = new_name
end
end
Как обычно, объявления типов или начальные значения могут использоваться для очень удобного синтаксиса. Существуют и другие полезные макросы, например макрос записи, как вы увидите далее в этой главе. В следующих главах вы также узнаете, как создавать собственные макросы для автоматизации генерации кода. Далее вы узнаете об основной концепции объектно-ориентированного программирования: классах, которые наследуются от других классов.
Классы могут основываться на других классах, обеспечивая более специализированное поведение. Когда класс наследует от другого, он получает все существующие методы и переменные экземпляра и может добавлять новые или перезаписывать существующие. Например, давайте расширим ранее определенный класс
Person
:
class Person
property name : String
def initialize(@name)
end
end
class Employee < Person
property salary = 0
end
Экземпляр
Employee
может находиться в любом месте, где требуется экземпляр Person
, поскольку, по сути, employee
– это человек:
person = Person.new("Alan")
employee = Employee.new("Helen")
employee.salary = 10000
p person.is_a? Person # => true
p employee.is_a? Person # => true
p person.is_a? Employee # => false
В этом примере родительским классом является
Person
, а дочерним - Employee
. Для создания иерархии классов можно создать несколько классов. При наследовании от существующего класса дочерний класс может не только расширять, но и переопределять части своего родительского класса. Давайте посмотрим на это на практике:
class Employee
def yearly_salary
12 * @salary
end
end
class SalesEmployee < Employee
property bonus = 0
def yearly_salary
12 * @salary + @bonus
end
end
В этом примере мы видим, что ранее определенный класс
Employee
повторно открывается для добавления нового метода. При повторном открытии класса не следует указывать его родительский класс (в данном случае Person
). Метод yearly_salary
добавляется к Employee
, а затем создается новый специализированный тип Employee
, наследуемый от него (и, в свою очередь, также наследуемый от Person
). Добавляется новое свойство и переопределяется yearly_ salary
, чтобы учесть его. Переопределение затрагивает только объекты типа SalesEmployee
, но не объекты типа Employee
.
При наследовании от класса и переопределении метода ключевое слово
super
может использоваться для вызова переопределенного определения из родительского класса. yearly_salary
можно было бы записать следующим образом:
def yearly_salary
super + @bonus
end
Поскольку метод
initialize
используется для подготовки начального состояния объекта, ожидается, что он всегда будет выполнен раньше всего остального. Таким образом, общепринятой практикой является использование ключевого слова super
для вызова конструктора родительского класса при наследовании от существующего класса.
Теперь, когда мы определили несколько классов и подклассов, мы можем воспользоваться еще одной мощной концепцией: объекты типа подкласса могут храниться в переменной, типизированной для хранения одного из его базовых классов.
SalesEmployee
наследуется от Employee
, чтобы определить более специализированный тип сотрудника, но это не меняет того факта, что сотрудник отдела продаж является сотрудником и может рассматриваться как таковой. Это называется полиморфизмом. Давайте посмотрим пример этого в действии:
employee1 = Employee.new("Helen")
employee1.salary = 5000
employee2 = SalesEmployee.new("Susan")
employee2.salary = 4000
employee2.bonus = 20000
employee3 = Employee.new("Eric")
employee3.salary = 4000
employee_list = [employee1, employee2, employee3]
Здесь мы создали трех разных сотрудников, а затем создали массив, содержащий их всех. Этот массив имеет тип
Array(Employee)
, хотя в нем также содержится SalesEmployee
. Этот массив можно использовать для вызова методов:
employee_list.each do |employee|
puts "#{employee.name}'s yearly salary is $#{employee. yearly_salary.format(decimal_places: 2)}."
end
Это приведет к следующему результату:
Elen's yearly salary is $60,000.00.
Susan's yearly salary is $68,000.00.
Eric's yearly salary is $48,000.00.
Как показано в этом примере, Crystal вызовет правильный метод, основанный на реальном типе объекта во время выполнения, даже если он статически типизирован как родительский класс.
Создание иерархии классов полезно не только для повторного использования кода, но и для обеспечения полиморфизма. Вы даже можете ввести в свою программу неполные классы, просто чтобы объединить похожие концепции. Некоторые из них должны быть абстрактными классами, как мы увидим далее.
Иногда мы пишем иерархию классов, и не имеет смысла разрешать создавать объекты на основе некоторых из них, потому что они не представляют конкретных понятий. Сейчас самое время пометить класс как абстрактный. Давайте рассмотрим пример:
abstract class Shape
end
class Circle < Shape
def initialize(@radius : Float64)
end
end
class Rectangle < Shape
def initialize(@width : Float64, @height : Float64)
end
end
И круги, и прямоугольники - это разновидности фигур, и они могут быть поняты сами по себе. Но форма сама по себе является чем-то абстрактным и была создана для наследования. Когда класс является абстрактным, его создание в виде объекта запрещено:
a = Circle.new(4)
b = Rectangle.new(2, 3)
c = Shape.new # This will fail to compile; it doesn't make sense.
Абстрактный класс не только накладывает ограничения, но и позволяет нам описать характеристики, которые должен реализовывать каждый подкласс. Абстрактные классы могут иметь абстрактные методы, а методы без определений должны быть переопределены:
abstract class Shape
abstract def area : Number
end
class Circle
def area : Number
Math::PI * @radius ** 2
end
end
class Rectangle
def area : Number
@width * @height
end
end
Определяя метод абстрактной области в родительском классе, мы гарантируем, что все подклассы должны будут определять его, используя одну и ту же сигнатуру (без аргументов, возвращая какое-либо число). Например, если у нас есть список фигур, мы можем быть уверены, что сможем вычислить площадь каждой из них.
Абстрактный класс не ограничивается абстрактными методами - он также может определять обычные методы и переменные экземпляра.
Объекты являются экземплярами определенного класса и хранят значения переменных его экземпляра. Хотя имена и типы переменных одинаковы, каждый экземпляр (каждый объект) может иметь разные значения для них. Если тип переменной экземпляра является объединением нескольких типов, то разные объекты могут хранить в себе значения разных типов. Класс описывает каркас, в то время как объекты являются живыми объектами.
Но классы тоже являются объектами! Разве у них не должны быть переменные экземпляра и методы? Да, конечно.
Когда вы создаете класс, вы можете определить переменные класса и методы класса. Они находятся в самом классе, а не в каком-либо конкретном объекте. Переменные класса обозначаются префиксом
@@
, точно так же, как переменные экземпляра имеют префикс @
. Давайте посмотрим на это на практике:
class Person
@@next_id = 1
@id : Int32
def initialize(@name : String)
@id = @@next_id
@@next_id += 1
end
end
Здесь мы определили переменную класса с именем
@@next_id
. Она существует сразу для всей программы. У нас также есть переменные экземпляра @name
и @id
, которые существуют для каждого объекта Person
:
first = Person.new("Adam") # This will have @id = 1
second = Person.new("Jess") # And this will have @id = 2
# @@next_id inside Person is now 3.
Имейте в виду, что эти переменные класса действуют как глобальные переменные, и их значения являются общими для всей программы. Хотя это полезно для некоторых глобальных состояний, это также не обеспечивает потокобезопасность в программах с включенным параллелизмом, поскольку могут возникнуть условия гонки. Предыдущий пример не является потокобезопасным, если экземпляры
Person
создаются из разных потоков. Crystal по умолчанию не является многопоточным.
Подобно переменным класса, методы класса можно определить в самом классе, добавив к его имени префикс
self
. Посмотри:
class Person
def self.reset_next_id
@@next_id = 1
end
end
Теперь вы можете вызвать
Person.reset_next_id
для выполнения этого действия, работая напрямую с классом. Отсюда становится ясно, что классы действительно являются объектами, поскольку у них есть данные и методы. Все это работает, как и ожидалось, и с наследованием подклассов.
Поскольку метод класса вызывается для класса, а не для экземпляра класса, в игре нет никакого объекта, а ключевое слово
self
относится к самому классу. Вы не можете получить доступ к переменным экземпляра или вызвать методы экземпляра, не обращаясь к какому-либо объекту.
Подобно переменным экземпляра, существуют вспомогательные макросы, помогающие предоставлять переменные класса с помощью методов класса, то есть
class_getter
, class_setter
и class_property
:
class Person
class_property next_id
end
Теперь можно сделать
Person.next_id = 3
или x = Person.next_id
.
Модули, как и абстрактные классы, не представляют собой конкретные классы, из которых можно создавать объекты. Вместо этого модули — это фрагменты класса реализации, которые можно включить в класс при его определении. Модули могут определять переменные экземпляра, методы, переменные класса, методы класса и абстрактные методы, все из которых внедряются в класс, который их включает.
Давайте рассмотрим пример модуля, который определяет метод
Say_name
на основе некоторого существующего метода имени:
module WithSayName
abstract def name : String
def say_name
puts "My name is #{name}"
end
end
Это можно использовать с вашим классом
Person
:
class Person
include WithSayName
property name : String
def initialize(@name : String)
end
end
Здесь метод имени, ожидаемый
WithSayName
, создается макросом свойства. Теперь мы можем создать новый экземпляр Person
и вызвать для него Say_name
.
Модули можно использовать для ограничений типа и типа переменных. Когда это будет сделано, он укажет любой класс, включающий этот модуль. Учитывая ранее определенный код, мы можем сделать следующее:
def show(thing : WithSayName)
thing.say_name
end
show Person.new("Jim")
Как обычно, ограничения типов не являются обязательными, но они могут помочь улучшить читаемость и документацию.
Модули часто используются для той же цели, что и интерфейсы других языков, где определен общий набор характеристик и один и тот же модуль реализуется множеством разных классов. Кроме того, один класс может включать в себя столько модулей, сколько необходимо.
Стандартная библиотека включает в себя несколько полезных модулей для указания характеристик некоторых классов:
Comparable: реализует все операторы сравнения при условии, что вы правильно реализовали оператор
<=>
. Классы, представляющие значения в естественном порядке, которые можно сортировать внутри контейнера, обычно включают этот модуль.
• Enumerable: используется для коллекций, элементы которых можно перечислять один за другим. Класс должен реализовать
each
метод. передавая каждый элемент в блок. Этот модуль, в свою очередь, реализует несколько вспомогательных методов для управления коллекцией.
• Iterable: это означает, что можно лениво перебирать включающую коллекцию. Класс должен реализовать
each
метод без получения блока и вернуть экземпляр Iterator
. Модуль добавит множество полезных методов для преобразования этого итератора.
• Indexable: предназначен для коллекций, элементы которых имеют числовую позицию в строгом порядке и могут рассчитываться от
0
до размера коллекции. Ожидается, что класс предоставит метод size
и unsafe_fetch
. Indexable
включает Enumerable
и Iterable
и предоставляет все их методы, а также некоторые дополнения для работы с индексами.
Подробнее о каждом из этих модулей можно прочитать в официальной документации по адресу https://crystal-lang.org/docs.
Мы уже обсуждали использование модулей в качестве миксинов (mixins), когда их основной целью является включение в другой существующий класс. Вместо этого модуль может использоваться просто как пространство
имен или выступать в качестве держателя переменных и методов. Примером этого является модуль Base64 из стандартной библиотеки – он просто предоставляет некоторые служебные методы и не предназначен для включения в класс:
# Prints "Crystal Rocks!":
p Base64.decode_string("Q3J5c3RhbCBSb2NrcyE=")
В данном случае Base64 – это просто группа связанных методов, доступ к которым осуществляется непосредственно из модуля. Это общий шаблон, который помогает организовать методы и классы.
Подробнее о различных вариантах использования модулей будет рассказано позже в этой книге. Мы многое узнали о классах и объектах, но не все объекты ведут себя одинаково. Далее давайте разберемся, в чем разница между значениями и ссылками.
По умолчанию объекты Crystal размещаются в памяти и управляются сборщиком мусора. Это означает, что вам не нужно беспокоиться о том, где находится каждый объект в памяти и как долго он должен жить - среда выполнения позаботится о том, чтобы учесть, на какие объекты все еще ссылаются некоторые переменные, и освободит все остальные, автоматически освобождая ресурсы. Переменные не будут хранить объект как таковой - они будут хранить ссылку, указывающую на объект. Все это работает прозрачно, и беспокоиться об этом не нужно.
Вышесказанное справедливо для всех объектов, созданных из классов; типы этих объектов являются ссылочными типами. Но есть и другой тип объектов: типы значений.
На следующей диаграмме вы можете увидеть цепочку наследования некоторых типов. Те, которые являются ссылками, наследуются от ссылочного класса, в то время как те, которые являются значениями, наследуются от структуры
Value
. Все они наследуются от специального базового типа Object
:
Рисунок 3.1 - Иерархия типов, показывающая, как ссылки связаны со значениями.
Ссылки управляются сборщиком мусора и хранятся в куче — специальной области памяти. Переменные указывают на них, и несколько переменных могут ссылаться на один и тот же объект. С другой стороны, объекты-значения живут в самих переменных и обычно имеют небольшой размер. Они создаются из структур.
Вы можете создавать свои собственные структуры. Они очень похожи на классы тем, что у них также есть переменные экземпляра и методы:
struct Address
property state : String, city : String
property line1 : String, line2 : String
property zip : String
def initialize(@state, @city, @line1, @line2, @zip)
end
end
Структуры и классы – это все типы объектов, и их можно использовать для ввода любой переменной, включая объединения типов. Например, давайте сохраним адрес внутри класса
Person
:
class Person
property address : Address?
end
В данном случае переменная экземпляра
@address
имеет тип Address?
это сокращение от Address | Nil
. Поскольку начального значения нет и эта переменная не назначается в методе initialize
, она начинается с nil
. Использование структуры является простым:
address = Address.new("CA", "Los Angeles", "Some fictitious line", "First house", "1234")
person1 = Person.new
person2 = Person.new
person1.address = address
address.zip = "ABCD"
person2.address = address
puts person1.address.try &.zip
puts person2.address.try &.zip
Мы начали этот пример с создания адреса и двух
persons
– в общей сложности трех объектов: одного объекта-значения и двух объектов-ссылок. Затем мы присвоили адрес из локальной переменной address
переменной экземпляра @address
для person1
. Поскольку адрес является значением, эта операция копирует данные. Мы изменяем его и присваиваем @address
person2
. Обратите внимание, что изменение не влияет на person1
– значения всегда копируются. Наконец, мы показываем почтовый индекс в каждом адресе. Нам нужно использовать метод try
для доступа к свойству zip
только в том случае, если на данный момент значение union
не равно nil
, поскольку компилятор не может определить это самостоятельно.
Поэкспериментируйте с изменением адреса класса и повторным запуском предыдущего кода. На этот раз у обоих пользователей будет одинаковый почтовый индекс. Это происходит потому, что ссылки не копируются при присвоении, поэтому все переменные будут ссылаться на один и тот же объект
address
.
Значения структуры всегда копируются, когда вы присваиваете их из одной переменной в другую, когда вы передаете их в качестве аргументов при вызове метода или когда вы получаете их из возвращаемого значения при вызове метода. Это известно как семантика "по значению"; таким образом, рекомендуется , чтобы структуры были небольшими с точки зрения объема их памяти. Из этого правила есть интересное и полезное исключение: когда тело метода просто возвращает переменную экземпляра напрямую, копия удаляется, и к значению осуществляется прямой доступ. Давайте рассмотрим пример:
struct Location
property latitude = 0.0, longitude = 0.0
end
class Building
property gps = Location.new
end
building = Building.new
building.gps.latitude = 1.5
p store
В предыдущем примере мы создали структурный тип
Location
, который имеет два свойства, и класс Building
, который имеет одно свойство. Макрос property gps
сгенерирует метод с именем def gps; @gps; end
для получателя - обратите внимание, что этот метод просто возвращает переменную экземпляра напрямую, что соответствует правилу исключения копирования. Если бы этот метод был каким-то другим, этот пример не сработал бы.
Строка
building.gps.latitude = 1.5
вызывает метод gps
и получает результат, затем вызывает параметр latitude=setter
с значением 1.5
в качестве аргумента. Если бы возвращаемое значение gps
было скопировано, то средство настройки работало бы с копией структуры и не влияло бы на значение, хранящееся в переменной building
. Попробуйте поэкспериментировать с добавлением пользовательского определения для метода gps
.
Теперь, когда вы знаете, как создавать как классы, так и структуры, мы сделаем шаг вперед и узнаем о дженериках и о том, как эта новая концепция может помочь вам создавать более гибкие типы.
Общий класс (или структура) создается на основе одного или нескольких неизвестных типов, которые определяются только позже, когда вы создаете экземпляр указанного класса. Это звучит сложно, но вы уже использовали некоторые общие классы раньше.
Array
является наиболее распространенным: заметили ли вы, что нам всегда нужно указывать тип данных, которые содержит массив? Недостаточно сказать, что данная переменная является массивом — мы должны сказать, что это массив строк или Array(String)
. Универсальный класс Hash
аналогичен, но у него есть два параметра типа — типы ключей и типы значений.
Давайте посмотрим на простой пример. Предположим, вы хотите создать класс, который содержит значение в одной из переменных экземпляра, но это значение может быть любого типа. Давайте посмотрим, как мы можем это сделать:
class Holder(T)
def initialize(@value : T)
end
def get
@value
end
def set(new_value : T)
@value = new_value
end
end
Общие параметры, по соглашению, представляют собой одиночные заглавные буквы — в данном случае
T
. В этом примере Holder
является универсальным классом, а Holder(Int32)
будет универсальным экземпляром этого класса: обычным классом, который может создавать объекты. Переменная экземпляра @value
имеет тип T
, независимо от того, какое T
будет позже. Вот как можно использовать этот класс:
num = Holder(Int32).new(10)
num.set 40
p num.get # Prints 40.
В этом примере мы создаем новый экземпляр класса
Holder(Int32)
. Это как если бы у вас был абстрактный класс Holder
и наследуемый от него класс Holder_Int32
, созданный по требованию для T=Int32
. Объект можно использовать как любой другой. Методы вызываются и взаимодействуют с переменной экземпляра @value
.
Обратите внимание, что в этих случаях тип
T
не обязательно указывать явно. Поскольку метод инициализации принимает аргумент типа T
, общий параметр можно вывести из использования. Давайте создадим Holder(String)
:
str = Holder.new("Hello")
p str.get # Prints "Hello".
Здесь
T
считается строкой, поскольку Holder.new
вызывается с аргументом строкового типа.
Классы-контейнеры из стандартной библиотеки являются универсальными классами, как и определенный нами класс
Holder
. Некоторые примеры: Array(T)
, Set(T)
и Hash(K, V)
. Вы можете поиграть с созданием собственных классов контейнеров, используя дженерики.
Далее давайте узнаем, как вызывать и обрабатывать исключения.
Существует множество способов, по которым код может сбоить. Некоторые сбои обнаруживаются во время анализа, например, невыполненный метод или нулевое значение в переменной, которое не должно содержать
nil
. Некоторые другие сбои происходят во время выполнения программы и описываются специальными объектами: исключениями. Исключение представляет собой сбой на "счастливом пути" и содержит точное местоположение, в котором была обнаружена ошибка, а также подробные сведения для ее понимания.
Исключение может быть вызвано в любой момент с помощью метода верхнего уровня raise. Этот метод ничего не вернет; вместо этого он начнет выполнять обратные вызовы всех методов, как если бы все они имели неявный возврат. Если ничто не фиксирует исключение выше в цепочке методов, программа завершит работу, и пользователю будут представлены подробные сведения об исключении. Приятным аспектом возникновения исключения является то, что оно не должно останавливать выполнение программы; вместо этого его можно перехватить и обработать, возобновив нормальное выполнение.
Давайте рассмотрим пример:
def half(num : Int)
if num.odd?
raise "The number #{num} isn't even"
end
num // 2
end
p half(4) # => 2
p half(5) # Unhandled exception: The number 5 isn't even (Exception)
p half(6) # This won't execute as we have aborted the program.
В предыдущем фрагменте мы определили метод
half
, который возвращает половину заданного целого числа, но только для четных чисел. Если задано нечетное число, это вызовет исключение. В этой программе нет ничего, что могло бы перехватить и обработать это исключение, поэтому программа завершит работу с сообщением о необработанном исключении.
Обратите внимание, что
raise "описание ошибки"
– это то же самое, что raise Exception. new("описание ошибки")
, поэтому будет создан объект exception. Exception
- это класс, единственная особенность которого заключается в том, что метод raise принимает только его объекты.
Чтобы показать разницу между ошибками во время компиляции и во время выполнения, попробуйте добавить
p half("привет")
к предыдущему примеру. Теперь это недопустимая программа (из-за несоответствия типов), и она даже не собирается, поэтому не может быть запущена. Ошибки во время выполнения обнаруживаются и сообщаются только во время выполнения программы.
Исключения могут быть зафиксированы и обработаны с помощью ключевого слова rescue. Оно чаще используется в выражениях
begin
и end
, но может использоваться непосредственно в телах методов или блоков. Вот пример:
begin
p half(3)
rescue
puts "can't compute half of 3!"
end
Если внутри выражения
begin
возникнет какое-либо исключение, независимо от того, насколько глубоко оно находится в цепочке вызовов метода, это исключение будет восстановлено в коде rescue
. Удобно иметь возможность обрабатывать все виды исключений за один раз, но вы также можете получить доступ к тому, что это за исключение, указав переменную:
begin
p half(3)
rescue error
puts "can't compute half of 3 because of #{error}"
end
Здесь мы зафиксировали объект exception и можем его проверить. Мы могли бы даже вызвать его снова, используя
raise error
. Та же концепция может быть применена к телам методов:
def half?(num)
half(num)
rescue
nil
end
p half? 2 # => 1
p half? 3 # => nil
p half? 4 # => 2
В этом примере у нас есть версия метода
half
, которая называется half?
. Этот метод возвращает объединение Int32 | Nil,
в зависимости от введенного номера.
Наконец, ключевое слово rescue также можно использовать встроенно, чтобы защитить одну строку кода от любого исключения и заменить ее значение. Метод
half?
можно реализовать следующим образом:
def half?(num)
half(num) rescue nil
end
В реальном мире обычной практикой является пойти наоборот и сначала реализовать метод, который возвращает
nil
в неудачном пути, а затем создать вариант, который вызывает исключение поверх первой реализации.
Стандартная библиотека содержит множество типов предопределенных исключений, таких как
DivisionByZeroError
, IndexError
и JSON::Error
. Каждый из них представляет различные типы ошибок. Это простые классы, которые наследуются от класса Exception
.
Поскольку исключения - это обычные объекты, а
Exception
- это класс, вы можете определять новые типы исключений, наследуя от них. Давайте посмотрим на это на практике:
class OddNumberError < Exception
def initialize(num : Int)
super("The number #{num} isn't even")
end
end
def half(num : Int32)
if num.odd?
raise OddNumberError.new(num)
end
num // 2
end
В этом примере мы создали класс с именем
OddNumberError
, который наследуется от Exception
. Таким образом, его объекты могут быть вызваны и сохранены. Затем мы переписываем метод half
, чтобы использовать этот более специфичный класс ошибок. Эти объекты могут иметь переменные экземпляра и методы, как обычно.
Теперь, когда мы определили класс ошибок, мы можем фиксировать ошибки только из одного конкретного класса, а не из всех возможных исключений. Рекомендуется обрабатывать только известный набор ошибок, с которыми вы можете справиться. Это можно сделать, указав ограничение типа для ключевого слова
rescue
:
def half?(num)
half(num)
rescue error : OddNumberError
nil
end
Вы можете повторить несколько блоков
rescue
, чтобы зафиксировать и обработать несколько различных типов исключений. Единственная ситуация, в которой вы не можете быть разборчивы, – это встроенное восстановление, поскольку оно всегда будет обрабатывать и заменять все исключения.
В этой главе вы узнали, как создавать классы и структуры, разобравшись в их различиях. Стало ясно, что каждое отдельное значение является объектом - даже сами классы являются объектами: объекты содержат данные, и ими можно манипулировать с помощью методов. Вы узнали, как наследовать и расширять классы, а также как создавать повторно используемые модули для организации вашего кода. Наконец, вы узнали об исключениях и о том, как использовать классы для создания ошибок пользовательского типа. Поскольку язык в значительной степени объектно-ориентирован, вы будете взаимодействовать с объектами практически в каждой строке кода. Знание того, как определять свои собственные классы, является важным навыком для написания программ на Crystal.
В следующей главе мы перейдем к решению более практических задач с использованием языка Crystal, написав несколько инструментов для интерфейса командной строки (CLI).