DRY blijven door meta programmeren

Gepubliceerd op: 10.I.2006 09:51 CET
Categorieën: ruby

In een poging DRY te blijven, ben ik in de wondere wereld van ruby meta programmeren terecht gekomen. Het was er mooi en dat wil ik graag delen.

Ik was bezig met het maken van een subclass van String, Html. Mijn variant kan wat specifieke manipulaties uitvoeren: absolutize(url) om alle links absolute te maken, escape om speciale tekens te vervangen en sanitize(tags = nil) om tags, welke niet toegestaan zijn, te verwijderen. Na de methoden geschreven te hebben en een TestCase aangemaakt, bedacht ik me dat ik ook een !- (past het daadwerkelijk object aan) en self.-variant (class methode waar een String object ingaat en gemodificeerde versie uitkomt) wil hebben.

Ik heb eerst alles omgeschreven naar een !-variant, m’n code werd hier eigenlijk veel duidelijker van; geen value.gsub!(.. meer maar gsub!(... En ik ben m’n varianten gaan toevoegen:

  def escape
    result = dup
    result.escape!
    result
  end

  def self.escape(value)
    result = Html.new(value)
    result.escape!
    result.to_s
  end

Ho wacht! Moet ik die nu voor alle drie de methoden doen?! Maar dat is niet DRY! Ik wil kunnen schrijven:

  def_variants :escape

Dus ik wil een (private) class methode welke ik aan kan roepen om de boven staande code te genereren. Daar gaan we:

  def self.def_variants(method)
    module_eval <<-EOS
      def #{method}
        t = dup
        t.#{method}!
        t
      end
      def self.#{method}
        t = new(t)
        t.#{method}!
        t.to_s
      end
    EOS
  end
  private_class_method :def_variants

Wouw dat werkt, in een keer goed! Hoewel, absolutize verwacht een argument, de url welke als basis gebruikt moet worden. Volgende poging:

  def self.def_variants(method, args = nil)
    module_eval <<-EOS
      def #{method}(#{args})
        t = dup
        t.#{method}!(#{args})
        t
      end
      def self.#{method}(t#{args.nil? ? '' : ', ' + args})
        t = new(t)
        t.#{method}!(#{args})
        t.to_s
      end
    EOS
  end

Nu kunnen we het volgende schrijven:

  def_variants :absolutize, 'url'

In twee keer goed! Hoewel, sanitize heeft een optioneel argument. Dat optionele deel moet binnen de methode eraf gehaald worden anders zou:

  def_variants :sanitize, 'tags = nil'

het volgende:

  def sanitize(tags = nil)
    t = dup
    t.sanitize(tags = nil)
  end

genereren en dat gaat natuurlijk niet werken. De hele argument lijst doorgeven als string is misschien geen goed idee, er kan immers allerlei narigheid in gezet worden. Voor mijn Html is een naïeve methode genoeg om de default waarde eraf te krijgen:

  def self.def_variants(method, args = nil)
    args_clean = args.gsub(/\s*=[^,]*/, '') if args
    module_eval <<-EOS
      def #{method}(#{args})
        t = dup
        t.#{method}!(#{args_clean})
        t
      end
      def self.#{method}(t#{args.nil? ? '' : ', ' + args})
        t = new(t)
        t.#{method}!(#{args_clean})
        t.to_s
      end
    EOS
  end

Dat is mooi, m’n probleem is opgelost! In, uhm, drie keer.

Nu ik hierover zit te schrijven ben ik geïnteresseerd geraakt in een meer sluitende oplossing, misschien zelfs een die herbruikbaar is voor andere classes. Eerst dat default waarden probleem netje oplossen. Een string om de argumenten lijst te beschrijven wordt niks. Dat zou betekenen dat de we de argumenten lijst moeten parsen met al ruby’s rariteiten daarbij. Misschien zou het mooi zijn om het volgende te kunnen schrijven:

  def_variants :absolutize, :url, {:tags => 'nil'}

Ziet er een beetje uit als het resultaat en geeft de gebruiker de mogelijkheid om weelderige default waarden te schrijven. De def_variants methode ziet er nu als volgt uit:

  def self.def_variants(method, *args)
    arg_def = args.map do |t|
      t.kind_of?(Hash) ? "#{t.keys[0]} = #{t[t.keys[0]]}" : t
    end.join(', ')
    arg_list = args.map{|t|(t.kind_of?(Hash) ? t.keys[0] : t)}.join(', ')
    module_eval <<-EOS
      def #{method}(#{arg_def})
        t = dup
        t.#{method}!(#{arg_list})
        t
      end
      def self.#{method}(t#{args.empty? ? '' : ', ' + arg_def})
        t = new(t)
        t.#{method}!(#{arg_list})
        t.to_s
      end
    EOS
  end

Als ik terug kijk, denk ik een veel te ingewikkelde oplossing te hebben ontwikkeld voor een trivaal probleem. Maar ik ben wel DRY gebleven en heb m’n ruby skills wat getrained.