DRY blijven door meta programmeren
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.