Чего мне хотелось бы от будущих версий руби, и как я справляюсь сейчас

в 12:40, , рубрики: ruby, ruby 2.0, workaround, Песочница, Программирование, метки: , ,

Добрый день.
Я около года работаю с 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js