matchable 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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