matchable 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20eea165b65f710ddb8430164ad48ac52a4893382b7ce73065964b3d35fcc5e3
4
- data.tar.gz: e1e50759a271a337147ef19c44c9f95d0725ef2f9556cdecbdb2e0a8d09ef9e9
3
+ metadata.gz: ea2b5bef957487e83b056970242e39508465ae5b3a4b466e881a71c8d235e7fd
4
+ data.tar.gz: 0d00fac4bda33448af11fdde57ee9bb010d1b50ff0be881084dea7a26d1b6dd2
5
5
  SHA512:
6
- metadata.gz: 5d1ec0a429ed529641461774b7ea5bab46e49e41c2fe7cabe1d91d2357d935b44c5a26e778ac111d35a9e272147bb77de843a4cba960a0ff623204cf3019584e
7
- data.tar.gz: fd72d05a74294d517a31dbfdfbc750c40c0bc84e4c2da3c7fd00c09f8009314d2c709789b467bf298d949a0d7ab05d0fc65e876a77a9ae5b3f26660732cde8f3
6
+ metadata.gz: 7f6cbb7043d57f74cb6131b536e277882aafaae7e05885ad79e7155707f236dd13263853f3ed212f0976f2fdd8e8ce1c21e2607504943a82b69f82fc56681b38
7
+ data.tar.gz: 78eed8f187eb98ef08c46fda76280123004b122c30a13d46ae9cab1bc412a8a66f3f266b5db808ebcd0d948e87af26090cf53f814c093e50dcaa1d26e416e545
data/.gitignore CHANGED
@@ -1,3 +1,4 @@
1
+ *.gem
1
2
  /.bundle/
2
3
  /.yardoc
3
4
  /_yardoc/
@@ -0,0 +1 @@
1
+ 3.0.0
data/Gemfile CHANGED
@@ -9,3 +9,4 @@ gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
11
  gem "guard-rspec"
12
+ gem "benchmark-ips"
@@ -1,11 +1,12 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- matchable (0.1.0)
4
+ matchable (0.1.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
+ benchmark-ips (2.8.4)
9
10
  coderay (1.1.3)
10
11
  diff-lcs (1.4.4)
11
12
  ffi (1.14.2)
@@ -60,6 +61,7 @@ PLATFORMS
60
61
  x86_64-darwin-19
61
62
 
62
63
  DEPENDENCIES
64
+ benchmark-ips
63
65
  guard-rspec
64
66
  matchable!
65
67
  rake (~> 13.0)
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env ruby -W0
2
+
3
+ require 'matchable'
4
+ require 'benchmark/ips'
5
+
6
+ class PersonMacro
7
+ include Matchable
8
+
9
+ deconstruct :new
10
+ deconstruct_keys :name, :age
11
+
12
+ attr_reader :name, :age
13
+
14
+ def initialize(name, age)
15
+ @name = name
16
+ @age = age
17
+ end
18
+ end
19
+
20
+ class PersonDynamic
21
+ VALID_KEYS = %i(name age)
22
+
23
+ attr_reader :name, :age
24
+
25
+ def initialize(name, age)
26
+ @name = name
27
+ @age = age
28
+ end
29
+
30
+ def deconstruct() = VALID_KEYS.map { public_send(_1) }
31
+
32
+ def deconstruct_keys(keys)
33
+ valid_keys = keys ? VALID_KEYS & keys : VALID_KEYS
34
+ valid_keys.to_h { [_1, public_send(_1)] }
35
+ end
36
+ end
37
+
38
+ alice_macro = PersonMacro.new('Alice', 42)
39
+ alice_dynamic = PersonDynamic.new('Alice', 42)
40
+
41
+ Benchmark.ips do |x|
42
+ x.report("[Person] Macro Generated - Full Hash") do
43
+ alice_macro in { name: /^A/, age: 30.. }
44
+ end
45
+
46
+ x.report("[Person] Macro Generated - Partial Hash") do
47
+ alice_macro in { name: /^A/ }
48
+ end
49
+
50
+ x.report("[Person] Macro Generated - Array") do
51
+ alice_macro in [/^A/, 30..]
52
+ end
53
+
54
+ x.report("[Person] Dynamic Generated - Full Hash") do
55
+ alice_dynamic in { name: /^A/, age: 30.. }
56
+ end
57
+
58
+ x.report("[Person] Dynamic Generated - Partial Hash") do
59
+ alice_dynamic in { name: /^A/ }
60
+ end
61
+
62
+ x.report("[Person] Dynamic Generated - Array") do
63
+ alice_dynamic in [/^A/, 30..]
64
+ end
65
+ end
66
+
67
+ puts '', '-' * 80, ''
68
+
69
+ # 26 attributes, should be enough to stress things out
70
+ LETTERS = ('a'..'z').to_a.map(&:to_sym)
71
+ LETTER_IVARS = LETTERS.map { "@#{_1} = #{_1}" }.join("\n")
72
+ LETTER_VALUES = LETTERS.each_with_index.to_h
73
+
74
+ # Easier than typing 26 attrs
75
+ eval <<~RUBY
76
+ class BigAttrMacro
77
+ include Matchable
78
+
79
+ deconstruct :new
80
+ deconstruct_keys *LETTERS
81
+
82
+ attr_reader *LETTERS
83
+
84
+ def initialize(#{LETTERS.join(', ')})
85
+ #{LETTER_IVARS}
86
+ end
87
+ end
88
+ RUBY
89
+
90
+ eval <<~RUBY
91
+ class BigAttrDynamic
92
+ VALID_KEYS = LETTERS
93
+
94
+ attr_reader *LETTERS
95
+
96
+ def initialize(#{LETTERS.join(', ')})
97
+ #{LETTER_IVARS}
98
+ end
99
+
100
+ def deconstruct() = VALID_KEYS.map { public_send(_1) }
101
+
102
+ def deconstruct_keys(keys)
103
+ valid_keys = keys ? VALID_KEYS & keys : VALID_KEYS
104
+ valid_keys.to_h { [_1, public_send(_1)] }
105
+ end
106
+ end
107
+ RUBY
108
+
109
+ big_attr_macro = BigAttrMacro.new(*1..26)
110
+ big_attr_dynamic = BigAttrDynamic.new(*1..26)
111
+
112
+ Benchmark.ips do |x|
113
+ x.report("[BigAttr] Macro Generated - Full Hash") do
114
+ big_attr_macro in {}
115
+ end
116
+
117
+ x.report("[BigAttr] Macro Generated - Partial Hash") do
118
+ big_attr_macro in { a:, b:, c:, d:, e:, f: }
119
+ end
120
+
121
+ x.report("[BigAttr] Macro Generated - Array") do
122
+ big_attr_macro in [1, 2, 3, *]
123
+ end
124
+
125
+ x.report("[BigAttr] Dynamic Generated - Full Hash") do
126
+ big_attr_dynamic in {}
127
+ end
128
+
129
+ x.report("[BigAttr] Dynamic Generated - Partial Hash") do
130
+ big_attr_dynamic in { a:, b:, c:, d:, e:, f: }
131
+ end
132
+
133
+ x.report("[BigAttr] Dynamic Generated - Array") do
134
+ big_attr_dynamic in [1, 2, 3, *]
135
+ end
136
+ end
@@ -0,0 +1,55 @@
1
+ Warming up --------------------------------------
2
+ [Person] Macro Generated - Full Hash
3
+ 108.660k i/100ms
4
+ [Person] Macro Generated - Partial Hash
5
+ 150.641k i/100ms
6
+ [Person] Macro Generated - Array
7
+ 209.579k i/100ms
8
+ [Person] Dynamic Generated - Full Hash
9
+ 102.585k i/100ms
10
+ [Person] Dynamic Generated - Partial Hash
11
+ 154.093k i/100ms
12
+ [Person] Dynamic Generated - Array
13
+ 136.640k i/100ms
14
+ Calculating -------------------------------------
15
+ [Person] Macro Generated - Full Hash
16
+ 1.104M (± 3.0%) i/s - 5.542M in 5.025098s
17
+ [Person] Macro Generated - Partial Hash
18
+ 1.657M (± 1.8%) i/s - 8.436M in 5.093239s
19
+ [Person] Macro Generated - Array
20
+ 2.101M (± 3.8%) i/s - 10.689M in 5.095977s
21
+ [Person] Dynamic Generated - Full Hash
22
+ 998.994k (± 2.1%) i/s - 5.027M in 5.033974s
23
+ [Person] Dynamic Generated - Partial Hash
24
+ 1.484M (± 3.6%) i/s - 7.551M in 5.093305s
25
+ [Person] Dynamic Generated - Array
26
+ 1.421M (± 2.5%) i/s - 7.105M in 5.002556s
27
+
28
+ --------------------------------------------------------------------------------
29
+
30
+ Warming up --------------------------------------
31
+ [BigAttr] Macro Generated - Full Hash
32
+ 62.462k i/100ms
33
+ [BigAttr] Macro Generated - Partial Hash
34
+ 57.960k i/100ms
35
+ [BigAttr] Macro Generated - Array
36
+ 159.136k i/100ms
37
+ [BigAttr] Dynamic Generated - Full Hash
38
+ 19.686k i/100ms
39
+ [BigAttr] Dynamic Generated - Partial Hash
40
+ 52.946k i/100ms
41
+ [BigAttr] Dynamic Generated - Array
42
+ 29.178k i/100ms
43
+ Calculating -------------------------------------
44
+ [BigAttr] Macro Generated - Full Hash
45
+ 644.864k (± 5.9%) i/s - 3.248M in 5.054710s
46
+ [BigAttr] Macro Generated - Partial Hash
47
+ 580.784k (± 3.4%) i/s - 2.956M in 5.096195s
48
+ [BigAttr] Macro Generated - Array
49
+ 1.568M (± 4.0%) i/s - 7.957M in 5.082464s
50
+ [BigAttr] Dynamic Generated - Full Hash
51
+ 194.429k (± 3.2%) i/s - 984.300k in 5.068253s
52
+ [BigAttr] Dynamic Generated - Partial Hash
53
+ 518.690k (± 3.7%) i/s - 2.594M in 5.008953s
54
+ [BigAttr] Dynamic Generated - Array
55
+ 295.488k (± 2.1%) i/s - 1.488M in 5.038268s
@@ -5,9 +5,30 @@ require_relative "matchable/version"
5
5
  # Interface for Pattern Matching hooks
6
6
  #
7
7
  # @author baweaver
8
- # @since 0.0.1
8
+ # @since 0.1.0
9
9
  #
10
10
  module Matchable
11
+ # Nicety wrapper to ensure unmatched methods give a clear response on what's
12
+ # missing
13
+ #
14
+ # @author baweaver
15
+ # @since 0.1.1
16
+ #
17
+ class UnmatchedName < StandardError
18
+ def initialize(msg)
19
+ @msg = <<~ERROR
20
+ Some attributes are missing methods for the match. Ensure all attributes
21
+ have a method of the same name, or an `attr_` method.
22
+
23
+ Original Error: #{msg}
24
+ ERROR
25
+
26
+ super(@msg)
27
+ end
28
+ end
29
+
30
+ DeconstructedBranch = Struct.new(:method_name, :code_branch, :guard_condition)
31
+
11
32
  # Constant to prepend methods and extensions to
12
33
  MODULE_NAME = "MatchableDeconstructors".freeze
13
34
 
@@ -17,7 +38,7 @@ module Matchable
17
38
  # Class method hooks for adding pattern matching interfaces
18
39
  #
19
40
  # @author baweaver
20
- # @since 0.0.1
41
+ # @since 0.1.0
21
42
  module ClassMethods
22
43
  # Hook for the `deconstruct` instance method which triggers its definition
23
44
  # based on a deconstruction method passed. If the method is not yet defined
@@ -28,12 +49,12 @@ module Matchable
28
49
  #
29
50
  # @return [Array[status, method_name]]
30
51
  def deconstruct(method_name)
31
- return if deconstructable_module.const_defined?('DECONSTRUCTION_METHOD')
52
+ return if matchable_module.const_defined?("MATCHABLE_METHOD")
32
53
 
33
54
  # :new should mean :initialize if one wants to match against arguments
34
55
  # to :new
35
56
  method_name = :initialize if method_name == :new
36
- deconstructable_module.const_set('DECONSTRUCTION_METHOD', method_name)
57
+ matchable_module.const_set("MATCHABLE_METHOD", method_name)
37
58
 
38
59
  # If this was called after the method was added, go ahead and attach,
39
60
  # otherwise we need some trickery to make sure the method is defined
@@ -77,26 +98,76 @@ module Matchable
77
98
  # @return [void]
78
99
  def deconstruct_keys(*keys)
79
100
  # Return early if called more than once
80
- return if deconstructable_module.const_defined?('DECONSTRUCTION_KEYS')
101
+ return if matchable_module.const_defined?('MATCHABLE_KEYS')
81
102
 
82
103
  # Ensure keys are symbols, then generate Ruby code for each
83
104
  # key assignment branch to be used below
84
- sym_keys = keys.map(&:to_sym)
85
- deconstructions = sym_keys.map { deconstructed_value(_1) }.join("\n\n")
105
+ sym_keys = keys.map(&:to_sym)
86
106
 
87
107
  # Retain a reference to which keys we deconstruct from
88
- deconstructable_module.const_set('DECONSTRUCTION_KEYS', sym_keys)
108
+ matchable_module.const_set('MATCHABLE_KEYS', sym_keys)
109
+
110
+ # Lazy Hash mapping of all keys to all values wrapped in lazy
111
+ # procs.
112
+ #
113
+ # see: #lazy_match_value
114
+ matchable_module.const_set('MATCHABLE_LAZY_VALUES', lazy_match_values(sym_keys))
89
115
 
90
116
  # `public_send` can be slow, and `to_h` and `each_with_object` can also
91
117
  # be slow. This defines the direct method calls in-line to prevent
92
118
  # any performance penalties to generate optimal match code.
93
- deconstructable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
119
+ #
120
+ # This generates and adds a method to the prepended module. We add YARDoc
121
+ # to this because the generated source can be seen and we want to be nice.
122
+ #
123
+ # We also intercept name errors to give more useful errors should it
124
+ # be implemented incorrectly.
125
+ matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
126
+ # Pattern Matching hooks for hash-like matches.
127
+ #
128
+ # This method was generated by Matchable. Make sure all properties have
129
+ # associated methods attached or this will raise an error.
130
+ #
131
+ # @param keys [Array[Symbol]]
132
+ # Keys to limit the deconstruction to. If keys are `nil` then return
133
+ # all possible keys instead.
134
+ #
135
+ # @return [Hash[Symbol, Any]]
136
+ # Deconstructed keys and values
94
137
  def deconstruct_keys(keys)
138
+ # If `keys` is `nil` we want to return all possible keys. This
139
+ # generates all of them as a direct Hash representation and
140
+ # returns that, rather than guard all methods below on
141
+ # `keys.nil? || ...`.
142
+ if keys.nil?
143
+ return {
144
+ #{nil_guard_values(sym_keys)}
145
+ }
146
+ end
147
+
148
+ # If keys are present, we want to iterate the keys to add requested
149
+ # values. Before we iterate we also want to ensure only valid keys
150
+ # are being passed through here.
95
151
  deconstructed_values = {}
152
+ valid_keys = MATCHABLE_KEYS & keys
96
153
 
97
- #{deconstructions}
154
+ # This is where things get interesting. Each value is retrieved through
155
+ # a lazy hash in which `method_name or `key` points to a proc:
156
+ #
157
+ # key: -> o { o.key }
158
+ #
159
+ # The actual method is interpolated directly and `eval`'d to make this
160
+ # faster than `public_send`.
161
+ valid_keys.each do |key|
162
+ deconstructed_values[key] = MATCHABLE_LAZY_VALUES[key].call(self)
163
+ end
98
164
 
165
+ # ...and once this is done, return back the deconstructed values.
99
166
  deconstructed_values
167
+ # We rescue `NameError` here to return a more useful message and indicate
168
+ # there are some missing methods for the match.
169
+ rescue NameError => e
170
+ raise Matchable::UnmatchedName, e
100
171
  end
101
172
  RUBY
102
173
 
@@ -104,26 +175,41 @@ module Matchable
104
175
  nil
105
176
  end
106
177
 
107
- # Generates Ruby code for `deconstruct_keys` branches which will
108
- # directly call the method rather than utilizing `public_send` or
109
- # similar methods.
178
+ # Generates key-value pairs of `method_name` pointing to `method_name` for
179
+ # the case where `keys` is `nil`, requiring all keys to be directly returned.
110
180
  #
111
- # Note that in the case of `keys` being `nil` it is expected to return
112
- # all keys that are possible from a pattern match rather than nothing,
113
- # hence adding this guard in every case.
114
- #
115
- # @param method_name [Symbol]
116
- # Name of the method to add a deconstructed key from
181
+ # @param method_names [Array[Symbol]]
182
+ # Names of the methods
117
183
  #
118
184
  # @return [String]
119
- # Evaluatable Ruby code for adding a deconstructed key to requested
120
- # values.
121
- private def deconstructed_value(method_name)
122
- <<~RUBY
123
- if keys.nil? || keys.include?(:#{method_name})
124
- deconstructed_values[:#{method_name}] = #{method_name}
125
- end
126
- RUBY
185
+ # Ruby code for all key-value pairs for method names
186
+ def nil_guard_values(method_names)
187
+ method_names
188
+ .map { |method_name| "#{method_name}: #{method_name}" }
189
+ .join(",\n")
190
+ end
191
+
192
+ # Generated Ruby Hash based on a mapping of valid keys to a lazy function
193
+ # to retrieve them directly without the need for `public_send` or similar
194
+ # methods. This code instead directly interpolates the method call and
195
+ # evaluates that, but will not run the code until called as a proc in the
196
+ # actual `deconstruct_keys` method.
197
+ #
198
+ # @param method_names [Array[Symbol]]
199
+ # Names of the methods
200
+ #
201
+ # @return [Hash[Symbol, Proc]]
202
+ # Mapping of deconstruction key to lazy retrieval function
203
+ def lazy_match_values(method_names)
204
+ method_names
205
+ # Name of the method points to a lazy function to retrieve it
206
+ .map { |method_name| " #{method_name}: -> o { o.#{method_name} }," }
207
+ # Join them into one String
208
+ .join("\n")
209
+ # Wrap them in Hash brackets
210
+ .then { |kv_pairs| "{\n#{kv_pairs}\n}"}
211
+ # ...and `eval` it to turn it into a Hash
212
+ .then { |ruby_code| eval ruby_code }
127
213
  end
128
214
 
129
215
  # Attaches the deconstructor to the parent class. If the method is
@@ -138,27 +224,60 @@ module Matchable
138
224
  private def attach_deconstructor(method_name)
139
225
  i_method = instance_method(method_name)
140
226
 
141
- deconstruction_code = if method_name == :initialize
142
- param_names = i_method.parameters.map(&:last)
227
+ deconstruction_code =
228
+ # If the method is `initialize` we want to treat it differently, as
229
+ # it represents a unique destructuring based on the method's parameters.
230
+ if method_name == :initialize
231
+ # Example of parameters:
232
+ #
233
+ # -> a, b = 2, *c, d:, e: 3, **f, &fn {}.parameters
234
+ # # => [
235
+ # # [:req, :a], [:opt, :b], [:rest, :c], [:keyreq, :d], [:key, :e],
236
+ # # [:keyrest, :f], [:block, :fn]
237
+ # # ]
238
+ #
239
+ # The `last` of each is the name of the param. This assumes a tied
240
+ # method to each of these names, and will fail otherwise.
241
+ param_names = i_method.parameters.map(&:last)
143
242
 
144
- "[#{param_names.join(', ')}]"
145
- else
146
- method_name
147
- end
243
+ # Take the literal names of those parameters and treat them like
244
+ # method calls to have the entire thing inlined
245
+ "[#{param_names.join(', ')}]"
246
+ # Otherwise we just want the method name, don't do anything special to
247
+ # this. If you have any other methods that might make sense here let me
248
+ # know by filing an issue.
249
+ else
250
+ method_name
251
+ end
148
252
 
149
- deconstructable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
253
+ # Then we evaluate that in the context of our prepended module and away
254
+ # we go with our new method. Added YARDoc because this will show up in the
255
+ # actual code and we want to be nice.
256
+ matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
257
+ # Pattern Matching hook for array-like deconstruction methods.
258
+ #
259
+ # This method was generated by Matchable and based on the `#{method_name}`
260
+ # method. Make sure all properties have associated methods attached or
261
+ # this will raise an error.
262
+ #
263
+ # @return [Array]
150
264
  def deconstruct
151
265
  #{deconstruction_code}
266
+ # We rescue `NameError` here to return a more useful message and indicate
267
+ # there are some missing methods for the match.
268
+ rescue NameError => e
269
+ raise Matchable::UnmatchedName, e
152
270
  end
153
271
  RUBY
154
272
 
273
+ # Return back nil because this value really should not be relied upon
155
274
  nil
156
275
  end
157
276
 
158
277
  # Prepended module to define methods against
159
278
  #
160
279
  # @return [Module]
161
- private def deconstructable_module
280
+ private def matchable_module
162
281
  if const_defined?(MODULE_NAME)
163
282
  const_get(MODULE_NAME)
164
283
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: matchable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brandon Weaver
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-01-31 00:00:00.000000000 Z
11
+ date: 2021-02-01 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -20,6 +20,7 @@ files:
20
20
  - ".github/workflows/main.yml"
21
21
  - ".gitignore"
22
22
  - ".rspec"
23
+ - ".ruby-version"
23
24
  - CODE_OF_CONDUCT.md
24
25
  - Gemfile
25
26
  - Gemfile.lock
@@ -27,6 +28,8 @@ files:
27
28
  - LICENSE.txt
28
29
  - README.md
29
30
  - Rakefile
31
+ - benchmarks/dynamic_vs_generated_benchmark.rb
32
+ - benchmarks/dynamic_vs_generated_benchmark_results.txt
30
33
  - bin/console
31
34
  - bin/setup
32
35
  - lib/matchable.rb