Добрый день.
Я около года работаю с Ruby и хотел бы написать о некоторых вещах, которых лично мне там часто не хватает, и которые я хотел бы видеть встроенными в язык. Пожалуй лишь пара из этих пунктов являются действительно серьезными недоработками, с остальными можно легко справиться подручными средствами.
Вроде бы недоработки и мелочь, но они ощутимо осложняют работу — приходится писать собственные библиотеки вспомогательных методов, которые и в гем не выделишь — уж больно маленькие, и без них некомфортно. А порой открываешь чужой код — и видишь там точно такие же вспомогательные функции как у тебя. Это как мне думается знак, что стандартная библиотека языка недоработана. Что же, будем надеяться, кто-нибудь из разработчиков прочтет текст и закоммитит патч. ;-)
Итак, начнем по порядку:
- Перегрузка метода разными списками аргументов, как в C++
- Отобразить хэш и получить из него другой хэш, а не массив
- Преобразовать экземпляр класса в экземпляр его же собственного подкласса
- Разные рюшечки
Перегрузка метода разными списками аргументов, как в C++
Вот это на мой взгляд одна из самых больших проблем руби. Он не позволяет определить метод по разному для разных типов аргументов. В результате приходится писать сложные конструкции разбора списка параметров, проверять типы, наличие-отсутствие аргументов итд. Я не претендую на решение этой проблемы, чтобы её решить надо перелопачивать исходники, писать много кода и ещё больше тестов. Поэтому я покажу лишь реализацию, которая в простейших случаях может сэкономить вам пару строк кода.
Для этого мы воспользуемся alias-method-chain'ингом.
module MethodOverload
module ClassMethods
def overload(meth, submethods_hash)
if method_defined?(meth)
alias_method "#{meth}_default_behavior", meth
has_default_behavior = true
end
define_method meth do |*args,&block|
submethods_hash.each do |sub_meth,arg_typelist|
if args.size == arg_typelist.size && args.to_enum.with_index.all?{|arg,index| arg.is_a? arg_typelist[index]}
return self.send(sub_meth,*args,&block)
end
end
if has_default_behavior
return self.send("#{meth}_default_behavior",*args,&block)
else
raise ArgumentError
end
end
end
end
def self.included(base)
base.extend(ClassMethods)
end
end
Напишем функцию такую, чтобы она выдавала следующие результаты:
x = X.new
x.f # => "original version []"
x.f 3, 'a' # => "original version [3,"a"]"
x.f 3, 4 # => "pair numeric version 3 & 4"
x.f 3 # => "numeric version 3"
x.f 'mystr' # => "string version mystr"
Вот как это выглядит, если имитировать перегрузку метода:
class X
include MethodOverload
def f(*args)
'original version ' + args.to_s
end
def f_string(str)
'string version ' + str.to_s
end
def f_numeric(num)
'numeric version ' + num.to_s
end
def f_pair_numeric(num1,num2)
"pair numeric version #{num1} & #{num2}"
end
overload :f, f_string:[String], f_numeric:[Numeric], f_pair_numeric:[Numeric, Numeric]
end
Вместо этого, конечно, можно просто написать одну функцию
class X
def f(*args)
if args.size == 2 && args.first.is_a? Numeric && args.last.is_a? Numeric
"pair numeric version #{args[0]} #{args[1]}"
elsif args.size == 1 && args.first.is_a? String
'string version ' + str.to_s
elsif args.size == 1 && args.first.is_a? Numeric
'numeric version ' + num.to_s
else
'original version ' + args.to_s
end
end
end
Однако такой метод очень быстро раздувается и становится неудобен для чтения и добавления возможностей. К сожалению в подходе описанном мной остаются проблемы такие как работа с аргументами по-умолчанию и сжатие списков (*args), но думаю их можно решить слегка расширив метод overload. Если сообществу покажется, что такой подход неплохо было бы развить — я попробую сделать на эту тему отдельную статью и расширю код.
Мне хотелось бы в будущих версиях руби иметь встроенные средства для подобной перегрузки метода.
Отобразить хэш и получить из него другой хэш, а не массив
Честно сказать я в недоумении, почему этого метода нет прямо в Enumerable, это одна из самых частых конструкций, которая бывает нужна. Её я безусловно хотел бы иметь отдельным методом, и даже не во втором руби, а чем раньше тем лучше (тем более, что это плевое дело).
У меня часто возикает задача пройтись по хэшу и сделать из него другой хэш. Этакий map. Вот только map для хэша вам выдаст что? Правильно, массив. Обычно приходится выкручиваться подобной конструкцией:
{a: 1, b:2, c:3, z:26}.inject({}){|hsh,(k,v)| hsh.merge(k=>v.even?)} # => {a: false, b: true, c:false, z: true}
Есть ещё один вариант
Hash[{a: 1, b:2, c:3, z:26}.map{|k,v| [k,v.even?]}]
Этот вариант чуть попроще и погибче т.к. позволяет отображать не только значения, но и ключи. Однако совершенно явно, что массивам не хватает метода to_hash, чтобы писать не Hash[arr], а arr.to_hash.
Однако не ко всем массивам такой метод будет применим, вероятно по этим причинам метода Array#to_hash и нет в ядре(см. обсуждение).
Это намекает, что стоит сделать производный от Array класс HashLikeArray, принимающий лишь массивы вида [[k1,v1],[k2,v2],...], но об этом в следующем пункте.
А пока реализуем простой hash_map, основанный на методе inject:
class Hash
def hash_map(&block)
inject({}) do |hsh,(k,v)|
hsh.merge( k=> block.call(v))
end
end
end
А теперь про реализацию класса, производного от Array. С этим возникают некоторые проблемы, которые мы сейчас будем пытаться решить.
Преобразовать экземпляр класса в экземпляр его же собственного подкласса
Понятно, что нет проблемы создать класс, производный от Array, но нам же нужно каким-то образом подменить метод Hash#to_a так, чтобы он возвращал не Array, а этот производный класс HashLikeArray. Вы, конечно, можете попробовать написать свою реализацию этого метода, но на самом-то деле вам нужна лишь обертка, которая превращает результат исходного Hash#to_a из класса Array в подкласс HashLikeArray.
Давайте попробуем написать (не заморачиваясь сейчас никакими alias'ами)
class HashLikeArray < Array
def initialize(*args,&block)
#raise unless ... - всякие проверки на то, правильного ли вида массив мы пытаемся задать
super
end
end
class Hash
def to_a_another_version
HashLikeArray[*self.to_a] # тут нам повезло, что есть метод Array::[]
end
end
{a:3,b:5,c:8}.to_a.class # ==> Array
{a:3,b:5,c:8}.to_a_another_version.class # ==> HashLikeArray
Справились. Теперь задачка посложнее, пусть у нас есть класс менее проработанный, чем Array:
class Animal
attr_accessor :weight
def self.get_flying_animal
res = self.new
res.singleton_class.class_eval { attr_accessor :flight_velocity}
res.flight_velocity = 1
res
end
end
class Bird < Animal
attr_accessor :flight_velocity
end
Теперь пусть у вас есть старый метод get_flying_animal, который писался, когда класса Bird ещё не существовало. Animal::get_flying_animal всегда возвращал птиц с проставленными аттрибутами, но чисто формально они были класса Animal. Теперь попробуйте не меняя класса Animal сделать метод Bird::get_flying_animal, который выдает тех же самых птиц по тому же алгоритму, но только теперь класса Bird. Да, метод get_flying_animal на самом деле гораздо объемней, чем в примере и дублировать его вы не хотите.
Особенно эта ситуация может испортить вам жизнь, если вы не можете менять класс Animal, либо даже не знаете его исходников, т.к. они написаны, например, на си. (В качестве упражнения, попробуйте написать свою версию метода HashLikeArray#to_a, не используя методов Array::[] или Array#replace. Дублировать код у вас просто неоткуда, если конечно, вы не собираетесь писать си-шную библиотеку)
Я придумал как это сделать лишь неэлегантным способом, копируя все переменные экземпляра в объект производного класса
class Object
def type_cast(new_class)
new_obj = new_class.allocate
instance_variables.each do |varname|
new_obj.instance_variable_set(varname, self.instance_variable_get(varname))
end
new_obj
end
end
Теперь можно сделать так:
def Bird.get_flying_animal
Animal.get_flying_animal.type_cast(Bird)
end
Если кто-то знает, как заменить этот неуклюжий костыль в виде метода type_cast — пишите, это крайне интересный вопрос.
Вообще говоря это спорный вопрос, насколько корректно так переопределять тип класса на тип подкласса. Я бы сказал, что это грязный хак и вообще не ООП, но иногда очень полезный хак… Руби — это такой специальный язык, где каждое правило нужно лишь за тем, чтобы было ясно, что именно можно нарушить. Думаю это не пошло бы сильно вразрез с принципами руби, если бы у объекта можно было поменять класс на любой другой уже после создания, директивно сказав что-то вроде:
x=X.new
x.class = Y
Ведь класс означает фактически лишь область для поиска методов, констант и @@-переменных, так какая разница, когда определять класс: в момент создания или после. Вся ответственность за целостность объекта с этого момента лежит на плечах пользователя, но это его право. Более того, если новый класс — подкласс старого, то ответственность большей частью делегирована старому классу.
Разные рюшечки
Теперь по мелочам:
Метод возвращающий self
Кроме того я бы предложил ввести метод вроде
def identity
self
end
Полезно это для того, чтобы можно было в блоках писать что-то вроде .collect(&:identity) вместо .collect{|x| x}
В случае с нумераторами и collect-ом все решается просто — методом to_a можно получить полный список объектов, а вот если вы пишете свой метод, принимающий блок, то функция может оказаться полезной.
Например сделаем метод наподобие рельсовского метода Nil#try .
class Object
def try(method_name,&block)
send method_name
rescue
yield
end
end
Теперь мы можем сделать
x.try(:transform,&:another_transform)
И вот тут нам может понадобиться наш метод identity:
x.try(:transform, &:identity)
— если преобразование сделать можно, оно будет сделано, нет — будет возвращен исходный объект.
Метод Object#in?(collection)
Рельсовский метод
color.in?([:red,:green,:blue])
гораздо лучше смотрится, чем
[:red,:green,:blue].include?(color)
Пишем:
def in?(collection)
raise ArgumentError unless collection.respond_to? :include?
collection.include? self
end
Ради этого метода подключать целую библиотеку (кажется ActiveSupport) очень лениво. Думаю, что этому методу в ядре жилось бы неплохо.
Отрицательная форма вопросительных методов
Хотелось бы писать arr.not_empty? вместо неуклюжего !arr.empty? Подобных вопросительных методов в руби очень много, а отрицаний почти что нет. Методы типа not_empty? и not_nil? хотелось бы иметь встроенными в стандартную библиотеку. Собственные вопрос-методы я предпочитаю доопределять отрицаниями с помощью method_missing (по понятным причинам method_missing у класса Object менять не рекомендуется)
class X
def method_missing(meth,*args,&block)
match = meth.to_s.match(/^not_(.+?)$/)
return !send(match[1], *args,&block) if match && respond_to?(match[1])
super
end
end
X.new.not_nil? # => true
Отрезаем расширения файлов
Отдельная ненависть у меня к модулю File, в котором нет методов filename_wo_extname и basename_wo_extname. Исправляем:
class File
def self.filename_wo_ext(filename)
filename[0..-(1+self.extname(filename).length)]
end
def self.basename_wo_ext(filename)
self.basename(filename)[0..-(1+self.extname(filename).length)]
end
end
File.basename_wo_ext('d:/my_folder/my_file.txt') # => "my_file"
File.filename_wo_ext('d:/my_folder/my_file.txt') # => "d:/my_folder/my_file"
Немного радостей
Мне следует загладить свою вину за такую гору жалоб перед — несмотря ни на что — замечательным языком Ruby. Поэтому поделюсь одним из радостных известий из мира руби — Ленивые нумераторы .
Автор: prijutme4ty