Довольно часто бывает, что разработчик, хорошо владеющий одним языком и попробовавший новый для себя, делает поспешные выводы и сравнения. Обычно подобные публикации довольно бесполезны, но броские заголовки дают хороший трафик.
Я решил, что было бы куда интереснее провести более честное сравнение, с точки зрения разработчика, которому нравятся оба языка и который имеет приличный опыт работы с ними. Например, с PHP и Ruby. И задача здесь не в том, чтобы выяснить, какой из них «лучше». Я лишь хочу подчеркнуть те свойства, которые мне нравятся в Ruby и его экосистеме.
Концептуальные различия
Разные языки зачастую сравнивают друг с другом, чтобы выбрать лучший из них, хотя различия между ними больше идеологического характера. Одно и то же свойство языка может быть благом для одних разработчиков, и ночным кошмаром для других. И прежде чем мы перейдём к сути дела, нужно объяснить некоторые из принципиальных различий между языками.
Метод, переменная, свойство?
Для доступа к свойствам, методам и переменным в РНР используется разный синтаксис, в отличие от Ruby.
PHP
$this->turtle # Свойство экземпляра
$this->bike() # Метод
$apple # Переменная
Ruby
@turtle # Свойство экземпляра
turtle # "Свойство экземпляра" с использованием attr_reader: :turtle
bike # Метод
apple # Переменная
Педанты, заметят, что
attr_reader :turtle
динамически определяет метод, используемый в качестве геттера для @turtle
, поэтому turtle
и bike
суть одно и то же. При этом PHP-разработчик не поймёт откуда что берётся, глядя на использование turtle
без явного определения имени метода или переменной.Иногда это может стать причиной путаницы. Например, ваш коллега по команде заменит элементы
attr_reader
на полные методы, или наоборот, что станет причиной недоразумений. Надо сказать, что при использовании некоторых очень гибких API это вполне допустимый ход, с помощью которого можно реализовать весьма дерзкие схемы. Вы удалили поле, но контракт JSON считает, что оно ещё присутствует, при этом осталось полно кода?
class Trip
def canceled_at
nil
end
end
Это отлично работает: если что-то вызовет
trip.canceled_at
, то вместо поля получит nil
. А позднее это можно корректно удалить.Указания типов (type hint) против «утиной» типизации
В мире РНР указания типов являются вещью одновременно странной и чудесной. В языках наподобие Go обязательно нужно указывать как типы аргументов, так и типы возвращаемых значений. Опциональное указание типов для аргументов появилось в РНР с пятой версии. Вы также можете запросить массивы, конкретные имена классов, интерфейсов или абстрактных объектов, а также недавно вызванные объекты.
В РНР 7.0 появилось указание типов для возвращаемых значений, а также поддержка хинтинга для
int
, string
, float
и т.д. Кроме того, были внедрены скалярные указания типов. Эта функциональность стала причиной многочисленных споров и дебатов, но в результате была реализована. Её использование полностью зависит от предпочтений самого программиста, что является хорошей новостью в свете разнообразия пользователей РНР.В Ruby ничего этого нет.
«Утиная» типизация — это отличный подход, поддерживаемый частью РНР-сообщества. Вместо того, чтобы заявить: «Аргумент должен быть экземпляром класса, реализующим
FooInterface
», в результате чего у FooInterface
будет метод bar(int $a, array $b)
, можно сказать иначе: «Аргумент может быть чем угодно, лишь бы реагировал на метод bar
. А если не будет реагировать, то придумаем что-нибудь ещё».Ruby
def read_data(source)
return source.read if source.respond_to?(:read)
return File.read(source.to_str) if source.respond_to?(:to_str)
raise ArgumentError
end
filename = "foo.txt"
read_data(filename) #=> считывает содержимое foo.txt с помощью вызова File.read()
input = File.open("foo.txt")
read_data(input) #=> считывает содержимое foo.txt с помощью передачи
# в дескриптор файла
Это по-настоящему гибкий подход, хотя некоторым такой код не по душе. Особенно в РНР, в котором
int(0)
и int(1)
в нестрогом режиме (weak mode) считаются корректными булевыми, принимающими любое значение, так что довольно рискованно надеяться, что всё будет работать как надо. В РНР мы могли бы просто определить два разных метода/функции:function read_from_filename(string $filename)
{
$file = new SplFileObject($filename, "r");
return read_from_object($file);
}
function read_from_object(SplFileObject $file)
{
return $file->fread($file->getSize());
}
$filename = "foo.txt";
read_from_filename($filename);
$file = new SplFileObject($filename, "r");
read_from_object($file);
Иными словами, в РНР можно легко использовать «утиную» типизацию:
function read_data($source)
{
if (method_exists($source, 'read')) {
return $source->read();
} elseif (is_string($source)) {
$file = new SplFileObject($source, "r"));
return $file->fread($file->getSize());
}
throw new InvalidArgumentException;
}
$filename = "foo.txt";
read_data($filename); #=> считывает содержимое foo.txt с помощью вызова
# SplFileObject->read();
$input = new SplFileObject("foo.txt", "r");
read_data($input); #=> считывает содержимое foo.txt с помощью передачи
# в дескриптор файла
Очень популярно заблуждение, что Ruby «лучше» РНР благодаря возможности использования «утиной» типизации. РНР позволяет использовать оба подхода, выбирайте любой. И этим РНР выгодно отличается от Ruby, в котором нет возможности применять type hint, даже если вам очень хочется. Многие РНР-программисты очень не любят указания типов и хотели бы, чтобы их вообще не было. К их сожалению, однако, в РНР 7.0 стало ещё больше type hint.
К слову, раньше в Python тоже не было указаний типов, но недавно их всё-таки внедрили.
Забавные возможности
Разобравшись с вышеописанным, можно сосредоточиться на забавных вещах. Некоторые из них вы можете применять достаточно часто, если не регулярно.
Вложенные классы
Для РНР-разработчика это довольно экзотическая вещь. Наши классы обитают в пространстве имён, при этом и класс, и само пространство могут иметь одинаковые наименования. Так что если у нас есть класс, имеющий отношение лишь к какому-то одному классу, то мы просто вносим его в пространство имён. Возьмём класс Box, который может кинуть исключение
ExplodingBoxException
:namespace Acme\Foo;
class Box
{
public function somethingBad()
{
throw new Box\ExplodingBoxException;
}
}
Это исключение должно где-то существовать. Можно положить его поверх класса, но у нас в одном файле два класса… в общем, многих это позабавит. Будет нарушен PSR-1, в котором говорится:
«Это значит, что каждый класс находится в отдельном файле, а в пространстве имён — как минимум занимает один уровень: имя поставщика верхнего уровня»
Вот его отдельный файл:
namespace Acme\Foo\Box;
class ExplodingBoxException {}
Для загрузки этого исключения придётся использовать автозагрузчик и снова обращаться к файловой системе. Но на это тратятся ресурсы! В РНР 5.6 избыточность повторных запросов снижается при включении кэша кода операции (opcode), но всё равно получается лишняя работа.
В Ruby можно вкладывать один класс в другой:
module Acme
module Foo
class Box
class ExplodingBoxError < StandardError; end
def something_bad!
raise ExplodingBoxError
end
end
end
end
Это можно делать как при определении класса, так и вне его:
begin
box = Acme::Foo::Box.new
box.something_bad!
rescue Acme::Foo::Box::ExplodingBoxError
# ...
end
Наверное, выглядит странно, но зато очень удобно. Класс имеет отношение только к одному классу? Сгруппируем их!
Другой пример связан с миграциями баз данных. Они используются во многих популярных РНР-фреймворках, от CodeIgniter до Laravel. Если в миграции вы ссылаетесь на модель или другой класс, и потом меняете его, то старые миграции очень причудливо ломаются.
В Ruby эта проблема красиво решается с помощью вложенных классов:
class PopulateEmployerWithUserAccountName < ActiveRecord::Migration
class User < ActiveRecord::Base
belongs_to :account
end
class Account < ActiveRecord::Base
has_many :users
end
def up
Account.find_each do |account|
account.users.update_all(employer: account.name)
end
end
def down
# Все пользователи, имеющие ID аккаунта, обновляются до предыдущего состояния
# «работодатель отсутствует»
User.where.not(account_id: nil).update_all(employer: nil)
end
end
Вместо глобальных объявленных классов будет использоваться вложенная версия ORM-моделей
User
и Account
. То есть при необходимости они могут выполнять для нас роль снэпшотов. Это куда полезнее, чем вызывать код в условиях, когда правила игры могут измениться в любой момент. Для кого-то всё это прозвучит дико, но только до тех пор, пока не столкнётесь с проблемами миграции.Отладчик
XDebug — вещь замечательная. Использование контрольных точек (breakpoints) совершило маленькую революцию в отладке PHP-приложений, позволив реализовать более продвинутую по сравнению с широко распространённой среди начинающих разработчиков схемой «
var_dump()
+ обновить».Иными словами, вам может стоить немалых усилий заставить XDebug работать с вашим IDE, найти правильный аддон (при необходимости), настроить php.ini, чтобы можно было использовать
zend_extension=xdebug.so
с вашим CLI и веб-версией приложения, получить отправленные контрольные точки, даже если вы используете Vagrant, и т.д.В Ruby немного другой подход. Как и при отладке JavaScript в браузере, вы можете просто вбить в код слово
debugger
, тем самым получив контрольную точку. Когда эта строка будет выполняться, не важно, что это — $ rails server
, модульный тест, интеграционный тест и т.д. — вам будет доступен экземпляр REPL, способный работать с вашим кодом.Существует ещё несколько отладчиков, самые популярные из которых
pry
и byebug
. Оба они являются gem'ами, и для их установки нужно через Bundler добавить в Gemfile
код:group :development, :test do
gem "byebug"
end
Это аналог зависимости dev Composer. Если вы используете Rails, то после установки достаточно вызвать
debugger
. В противном случае сначала надо выполнить require "byebug"
.В руководстве по Rails рассказывается, как всё работает после внедрения в ваше приложение соответствующего ключевого слова:
[1, 10] in /PathTo/project/app/controllers/articles_controller.rb
3:
4: # GET /articles
5: # GET /articles.json
6: def index
7: byebug
=> 8: @articles = Article.find_recent
9:
10: respond_to do |format|
11: format.html # index.html.erb
12: format.json { render json: @articles }
(byebug)
Стрелка указывает на строку, запускающую экземпляр REPL. Вы можете прямо с неё начинать исполнять свой код. В том месте
@articles
ещё не определён, но зато можно вызвать Article.find_recent
и посмотреть, что будет. Если вылетит ошибка, то можно набрать next
и перейти на следующую строку в том же контексте. Либо набрать step
и выполнить следующую инструкцию.Это удобно, особенно если вы пытаетесь понять, почему код не выводит то, что нужно. Можно проверять работу каждого значения в данном контексте, а затем копировать рабочий код в файл вне зависимости от результата исполнения. Невероятно удобно для тестирования.
Unless
Многим не нравится
unless
. Им часто злоупотребляют, как и многими другими вещами во многих других языках. Unless
не даёт людям покоя уже много лет, как подмечено в одной статье от 2008 года.Структура
unless
является «антонимом» if
. Код блокируется, если условие принимает значение true
, и продолжает выполняться при значении false
.unless foo?
# bar that thing
end
# Or
def something
return false unless foo?
# bar that thing
end
Это несколько облегчает работу, особенно при большом количестве условий. Могут использоваться || и круглые скобки. Вместо такой строки:
if ! (foo || bar) && baz
можно написать:
unless (foo || bar) && baz
Возможно, это будет уже слишком, и вам никто не позволит использовать
unless
вместе с else
, но сам по себе это удобный инструмент. О его внедрении в РНР просили ещё в 2007 году, но какое-то время просьба игнорировалась. Наконец Расмус Лердорф, создатель РНР, сообщил, что функция unless()
нарушит обратную совместимость, и это «не будет очевидно для тех, для кого английский язык не является родным».«Это странное слово, по сути означающее «нет, если», хотя по логике должно быть аналогом того, как «меньше (less)» является противоположностью «больше», но при этом приставка “un” меняет его значение на противоположное (less — unless).»
Спорное высказывание. Когда люди читают слово
unless
, то они не воспринимают его значение как «противоположное less» только из-за приставки un
. Иначе мы бы читали функцию uniqid()
и считали её противоположностью iqid()
.Предикатные методы (Predicate Methods)
В Ruby есть ряд интересных соглашений, которые по-другому решены в РНР. Одна из них — предикатные методы, то есть методы с булевым типом отклика (boolean response type). Учитывая, что в Ruby не возвращаются указания типов, подобные методы могут быть хорошим подспорьем.
Многие из предикатных методов уже встроены в Ruby, например,
object.nil?
. По сути, это аналог $object === nil
в РНР. Если нужно что-то запросить, а не выполнить действие, то лучше использовать include?
вместо include
.Можно задавать и собственные предикатные методы:
class Rider
def driver?
!potential_driver.nil? && vehicle.present?
end
end
Многие РНР-разработчики при создании предикатного метода добавляют к имени префикс
is
и/или has
, в результате получается, например, isDriver()
или hasVehicle()
. Но иногда можно встретить и другие префиксы. Допустим, метод can_drive?
из Ruby может превратиться в canDrive()
в РНР, при этом будет не совсем понятно, что это предикатный метод. Лучше переименовать его, в соответствии с традицией, в нечто вроде isAbleToDrive()
.Ещё более лаконичный синтаксис массивов
В РНР можно легко определять литеральные массивы, и начиная с РНР 5.4 появился ещё более лаконичный синтаксис:
// < 5.4
$a = array('one' => 1, 'two' => 2, 'three' => 'three');
// >= 5.4
$a = ['one' => 1, 'two' => 2, 'three' => 'three'];
Кто-то может сказать, что в данном случае с краткостью несколько переборщили. В Ruby 1.9 появилась опция, позволяющая заменять => на точку с запятой. В РНР можно было бы пойти ещё дальше:
$a = ['one': 1, 'two': 2, 'three': 'three'];
В ряде случаев это было бы действительно удобно, например, когда приходится несколько сотен раз за день набивать вложенные массивы. Предложение по упрощению синтаксиса было размещено ещё в 2011 году, но до сих пор не принято. Возможно, что и не примут. В РНР стараются сохранить минимализм синтаксиса и очень редко внедряют новые способы реализации старых вещей, даже если новинка обещает немало преимуществ. Можно сказать, что синтаксические пряники не являются приоритетом для команды РНР, в то время как в Ruby это едва ли не главное направление.
Объектные литералы
Эту функциональность, имеющуюся в Ruby, многие разработчики хотели бы видеть и в РНР. Если здесь вам нужно определить класс
StdClass
со значениями, то у вас есть два пути:$esQuery = new stdClass;
$esQuery->query = new stdClass;
$esQuery->query->term = new stdClass;
$esQuery->query->term->name = 'beer';
$esQuery->size = 1;
// или
$esQuery = (object) array(
"query" => (object) array(
"term" => (object) array(
"name" => "beer"
)
),
"size" => 1
);
В РНР так было всегда, но ведь можно делать и гораздо проще. Скажем, почти целиком позаимствовав синтаксис из Ruby:
PHP
$esQuery = {
"query" : {
"term" : {
"name" : "beer"
}
},
"size" : 1
};
Ruby
esQuery = {
"query" : {
"term" : {
"name" : "beer"
}
},
"size" : 1
}
Очень хотелось бы увидеть это нововведение в РНР, но пока что интереса со стороны разработчиков нет.
Метод Rescue
В PHP есть
try/catch
, а в Ruby ― begin/rescue
. Работают они практически одинаково, особенно в свете появления в PHP 5.6 finally
, как аналога ensure
из Ruby. В обоих языках есть возможность восстановления после исключения из любого места, для чего служат команды try
и begin
. Но Ruby позволяет делать гораздо больше: вы можете пропустить метод begin и восстановиться напрямую из тела функции/метода:Ruby
def create(params)
do_something_complicated(params)
true
rescue SomeException
false
end
Если что-то пойдёт не так, то можно будет каким-то образом обработать ошибку, а не злиться и не звонить кому-то, чтобы с этим разобрались. Это не панацея, но лучше иметь разные возможности, без необходимости оборачивать всё в
begin
.К сожалению, в РНР этот подход не работает, но если бы его реализовали, то код мог бы выглядеть примерно так:
function create($params) {
do_something_complicated($params);
return true;
} catch (SomeException $e) {
return false;
}
На первый взгляд, вещь не слишком важная. Но именно подобные многочисленные мелкие удобства в Ruby создают ощущение, что язык действительно старается помочь вам, быть полезным.
Повторные попытки после исключений
Очень удобным инструментом является команда
retry
:begin
SomeModel.find_or_create_by(user: user)
rescue ActiveRecord::RecordNotUnique
retry
end
В этом примере соревнование между языками снова обостряется благодаря неатомарности
find_or_create_by
(ORM сначала выполняет SELECT
, а затем INSERT
). И если вам не повезёт, то другой процесс может создать запись после SELECT
, но до INSERT
.Раз уже такое может произойти, то лучше с помощью
SELECT
предусмотреть возможность повторной попытки выполнения begin...rescue
. Возможно, в каких-то случаях вы даже захотите поместить сюда некую логику, чтобы прогнать её один-два раза, но мы не будем рассматривать этот вариант. Давайте лучше оценим удобство повторного исполнения какого-то куска кода. В Ruby это можно сделать так:def upload_image
begin
obj = s3.bucket('bucket-name').object('key')
obj.upload_file('/path/to/source/file')
rescue AWS::S3::UploadException
retry
end
end
А в PHP сначала потребуется создать новую функцию/метод для содержимого начального блока:
function upload_image($path) {
$attempt = function() use ($path) {
$obj = $this->s3->bucket('bucket-name')->object('key');
$obj->upload_file($path);
};
try {
$attempt();
} catch (AWS\S3\UploadException $e)
$attempt();
}
}
Возможно, в обоих случаях вы захотите использовать какие-то шаблонные заготовки для остановки цикла. Но с точки зрения повторного исполнения код Ruby получается гораздо более чистым. Ходят слухи, что сейчас ведётся активная работа над этой функциональностью. Возможно, мы увидим её в версии РНР 7.1.
Несколько мыслей напоследок
Не так давно я подметил за собой, что пишу на Ruby так же, как на РНР. Но совместная работа с сильными и опытными Ruby-разработчиками научила меня ряду характерных, несколько отличающихся подходов. Так что в этой статье я отразил то, чего мне не хватало бы в этом языке, если бы я вернулся к РНР, но не на столько, чтобы удержать меня от возвращения. Многие РНР-хейтеры игнорируют нововведения, внедряемые в РНР. И пусть здесь нет каких-то удобных и полезных вещей, свойственных Ruby, но зато в РНР 7 присутствует немало других очень интересных возможностей.
В последние годы РНР заметно улучшился с точки зрения консистентности, в частности за счёт единообразного синтаксиса переменных (uniform variable syntax), контекстно-зависимого лексического анализатора (context-sensitive lexer) и дерева абстрактного синтаксиса (abstract syntax tree). Всё это позволяет РНР быть гораздо более консистентным вне зависимости от степени консистентности стандартной библиотеки.
Поскольку стандартная библиотека в обозримом будущем вряд ли подвергнется улучшениям, то стоит только приветствовать появление новых удобных возможностей и синтаксических приёмов. Другие языки могут сколько угодно экспериментировать, пробовать разные теории и новинки, давая пищу для новостных лент программерских ресурсов. А РНР будет заимствовать именно то, что нужно этому языку. Это эффективный подход. Можно считать РНР пиратом-мародёром.
Если у вас есть время и желание, то экспериментируйте с самыми разными языками. Ruby может быть хорошим началом, Go может быть весьма забавным, а Elixir запутан, но увлекателен. Но это не призыв метаться от одного к другому. Можно уделять каждому языку достаточно времени, чтобы войти во вкус, свыкнуться и привести свой разум в тонус.