Skip to content
This repository has been archived by the owner on Oct 14, 2022. It is now read-only.

Commit

Permalink
Merge pull request #3 from st-tech/perfect-teyu
Browse files Browse the repository at this point in the history
Fastest version
  • Loading branch information
takanamito authored Nov 10, 2019
2 parents c0f064a + ce5b0cf commit f7d1c58
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 12 deletions.
94 changes: 82 additions & 12 deletions lib/teyu.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,60 @@ module Teyu
class Error < StandardError; end

def teyu_init(*params)
define_initializer = DefineInitializer.new(self, params)
define_initializer.apply
argument = Teyu::Argument.new(params)
begin
Teyu::FastInitializer.new(self, argument).define
rescue SyntaxError # fallback to slow, but generic initializer if failed
Teyu::GenericInitializer.new(self, argument).define
end
end

class DefineInitializer
def initialize(klass, params)
class FastInitializer
def initialize(klass, argument)
@klass = klass
@params = params
@argument = argument
end

def define
@klass.class_eval(def_initialize, __FILE__, __LINE__)
end

private def def_initialize
<<~EOS
def initialize(#{def_initialize_args})
#{def_initialize_body}
end
EOS
end

private def def_initialize_args
args = []
args << "#{@argument.required_positional_args.map(&:to_s).join(', ')}"
args << "#{@argument.required_keyword_args.map { |arg| "#{arg}:" }.join(', ')}"
# LIMITATION:
# supports only default values which can be stringified such as `1`, `"a"`, `[1]`, `{a: 1}`.
# Note that the default value objects are newly created everytime on a method call.
args << "#{@argument.optional_keyword_args.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')}"
args.reject { |arg| arg.empty? }.join(', ')
end

private def def_initialize_body
@argument.arg_names.map { |name| "@#{name} = #{name}" }.join("\n ")
end
end

class GenericInitializer
def initialize(klass, argument)
@klass = klass
@argument = argument
end

def apply
argument = Teyu::Argument.new(@params)
def define
# NOTE: accessing local vars is faster than method calls, so cache to local vars
required_positional_args = argument.required_positional_args
required_keyword_args = argument.required_keyword_args
optional_keyword_args = argument.optional_keyword_args
keyword_args = argument.keyword_args
required_positional_args = @argument.required_positional_args
required_keyword_args = @argument.required_keyword_args
optional_keyword_args = @argument.optional_keyword_args
keyword_args = @argument.keyword_args

@klass.define_method(:initialize) do |*given_args|
if given_args.last.is_a?(Hash)
Expand Down Expand Up @@ -54,6 +91,25 @@ def apply
while i < default_keyword_args_keys.size
name = default_keyword_args_keys[i]
value = optional_keyword_args[name]
# NOTE: In Ruby, objects of default arguments are newly created everytime on a method call.
#
# def test(a: "a")
# puts a.object_id
# end
# test #=> 70273097887660
# test #=> 70273097887860
#
# In a method argument, it is possible to suppress the new object creation like:
#
# $a = "a"
# def test(a: $a)
# puts a.object_id
# end
# test #=> 70273097887860
# test #=> 70273097887860
#
# But, we do not support a such feature in this gem. That's why we `dup` here.
value = value.dup
instance_variable_set(:"@#{name}", value)
i += 1
end
Expand All @@ -71,9 +127,23 @@ def apply

class Argument
REQUIRED_SYMBOL = '!'.freeze
VARIABLE_NAME_REGEXP = /\A[a-z_][a-z0-9_]*\z/

def initialize(params)
@params = params
validate
end

private def validate
invalid_variable_names = arg_names.reject { |name| VARIABLE_NAME_REGEXP.match?(name) }
unless invalid_variable_names.empty?
raise ArgumentError, "invalid variable names: #{invalid_variable_names.join(', ')}"
end
end

# @return [Array<Symbol>] names of arguments
def arg_names
@arg_names ||= required_positional_args + required_keyword_args + optional_keyword_args.keys
end

# method(a, b) => [:a, :b]
Expand All @@ -92,7 +162,7 @@ def keyword_args
# @return [Array<Symbol>] names of required keyword arguments
def required_keyword_args
@required_keyword_args ||= @params.map(&:to_s).select { |arg| arg.end_with?(REQUIRED_SYMBOL) }
.map { |arg| arg.delete_suffix(REQUIRED_SYMBOL).to_sym }
.map { |arg| arg.delete_suffix(REQUIRED_SYMBOL).to_sym }
end

# method(a: 'a', b: 'b') => { a: 'a', b: 'b' }
Expand Down
10 changes: 10 additions & 0 deletions test/argument_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ def setup
@argument = Teyu::Argument.new([:a, :b, :c!, { d: 'd' }, { e: 'e' }, :f!])
end

def test_arg_names
assert { @argument.arg_names == [:a, :b, :c, :f, :d, :e] }
end

def test_required_positional_args
assert { @argument.required_positional_args == [:a, :b] }
end
Expand All @@ -20,5 +24,11 @@ def test_required_keyword_args
def test_optional_keyword_args
assert { @argument.optional_keyword_args == { d: 'd', e: 'e' } }
end

def test_validate
assert_raises ArgumentError do
Teyu::Argument.new([:"a); def initialize("])
end
end
end

45 changes: 45 additions & 0 deletions test/teyu_test.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require_relative "test_helper"
require 'ostruct'

class TeyuTest < Test::Unit::TestCase
def test_that_it_has_a_version_number
Expand Down Expand Up @@ -106,5 +107,49 @@ def test_mixed_required_and_optional_keyword_args
assert { example.instance_variable_get('@fuga') == 'Fuga' }
assert { example.instance_variable_get('@piyo') == 'PIYO' }
end

def test_optional_keyword_args_with_various_types
klass = Class.new do
extend Teyu
teyu_init str: 'str', int: 1, arr: [1, '1'], hash: {key: 1}
end

example = klass.new
assert { example.instance_variable_get('@str') == 'str' }
assert { example.instance_variable_get('@int') == 1 }
assert { example.instance_variable_get('@arr') == [1, '1'] }
assert { example.instance_variable_get('@hash') == {key: 1} }
end

def test_optional_keyword_args_with_objects
obj = OpenStruct.new(k: "v")
klass = Class.new do
extend Teyu
teyu_init a: obj
end

example = klass.new()
assert { example.instance_variable_get('@a') == obj }
end

def test_optional_keyword_args_that_values_are_newly_created
klass = Class.new do
extend Teyu
teyu_init :foo, bar: 'Bar'
end

bar1 = klass.new('Foo').instance_variable_get('@bar')
bar2 = klass.new('Foo').instance_variable_get('@bar')
assert { bar1.object_id != bar2.object_id }
end

def test_define_invalid_names
assert_raises ArgumentError do
Class.new do
extend Teyu
teyu_init :"a); File.read('/etc/password'); def initialize("
end
end
end
end

0 comments on commit f7d1c58

Please sign in to comment.