sums_up 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +19 -0
- data/.ruby-version +1 -0
- data/.travis.yml +10 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +815 -0
- data/Rakefile +11 -0
- data/bin/console +8 -0
- data/bin/setup +6 -0
- data/lib/sums_up.rb +31 -0
- data/lib/sums_up/CHANGELOG.md +5 -0
- data/lib/sums_up/core.rb +32 -0
- data/lib/sums_up/core/functions.rb +14 -0
- data/lib/sums_up/core/matcher.rb +133 -0
- data/lib/sums_up/core/parser.rb +68 -0
- data/lib/sums_up/core/strings.rb +17 -0
- data/lib/sums_up/core/sum_type.rb +65 -0
- data/lib/sums_up/core/variant.rb +137 -0
- data/lib/sums_up/maybe.rb +40 -0
- data/lib/sums_up/result.rb +36 -0
- data/lib/sums_up/version.rb +5 -0
- data/sums_up.gemspec +34 -0
- metadata +128 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e7e0c128cef808336972ac4b012e8d7d7c4fafa6
|
4
|
+
data.tar.gz: 777387cbd3a186d93e81f24e0190ee031ab6415a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 2083e37f3b61a5240fb6d42769ce946f6b36dbaa2da7fe4c2f5c9392cb62c2c5956c834637717ec3ba9c54f6bb4f3303709acc691ffff4423d2cf35f1fdb0c4c
|
7
|
+
data.tar.gz: 49596b05073fc5d42365efb07c750403fa8db6f4a2544f3a9bab999cafd05ab279c6b1ea8efe250ab89139bf383d8a1a5de86ff4f7c8647f5a44aa1d6364527c
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
AllCops:
|
2
|
+
NewCops: enable
|
3
|
+
SuggestExtensions: false
|
4
|
+
TargetRubyVersion: 2.4.10
|
5
|
+
|
6
|
+
Layout/LineLength:
|
7
|
+
Max: 80
|
8
|
+
|
9
|
+
Layout/MultilineMethodCallIndentation:
|
10
|
+
EnforcedStyle: indented
|
11
|
+
|
12
|
+
Metrics/AbcSize:
|
13
|
+
Enabled: false
|
14
|
+
|
15
|
+
Metrics/BlockLength:
|
16
|
+
Enabled: false
|
17
|
+
|
18
|
+
Metrics/MethodLength:
|
19
|
+
Enabled: false
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.4.10
|
data/.travis.yml
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
In the interest of fostering an open and welcoming environment, we as
|
6
|
+
contributors and maintainers pledge to making participation in our project and
|
7
|
+
our community a harassment-free experience for everyone, regardless of age, body
|
8
|
+
size, disability, ethnicity, gender identity and expression, level of experience,
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity and
|
10
|
+
orientation.
|
11
|
+
|
12
|
+
## Our Standards
|
13
|
+
|
14
|
+
Examples of behavior that contributes to creating a positive environment
|
15
|
+
include:
|
16
|
+
|
17
|
+
* Using welcoming and inclusive language
|
18
|
+
* Being respectful of differing viewpoints and experiences
|
19
|
+
* Gracefully accepting constructive criticism
|
20
|
+
* Focusing on what is best for the community
|
21
|
+
* Showing empathy towards other community members
|
22
|
+
|
23
|
+
Examples of unacceptable behavior by participants include:
|
24
|
+
|
25
|
+
* The use of sexualized language or imagery and unwelcome sexual attention or
|
26
|
+
advances
|
27
|
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
28
|
+
* Public or private harassment
|
29
|
+
* Publishing others' private information, such as a physical or electronic
|
30
|
+
address, without explicit permission
|
31
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
32
|
+
professional setting
|
33
|
+
|
34
|
+
## Our Responsibilities
|
35
|
+
|
36
|
+
Project maintainers are responsible for clarifying the standards of acceptable
|
37
|
+
behavior and are expected to take appropriate and fair corrective action in
|
38
|
+
response to any instances of unacceptable behavior.
|
39
|
+
|
40
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
41
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
42
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
43
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
44
|
+
threatening, offensive, or harmful.
|
45
|
+
|
46
|
+
## Scope
|
47
|
+
|
48
|
+
This Code of Conduct applies both within project spaces and in public spaces
|
49
|
+
when an individual is representing the project or its community. Examples of
|
50
|
+
representing a project or community include using an official project e-mail
|
51
|
+
address, posting via an official social media account, or acting as an appointed
|
52
|
+
representative at an online or offline event. Representation of a project may be
|
53
|
+
further defined and clarified by project maintainers.
|
54
|
+
|
55
|
+
## Enforcement
|
56
|
+
|
57
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
58
|
+
reported by contacting the project team at hulihan.tom159@gmail.com. All
|
59
|
+
complaints will be reviewed and investigated and will result in a response that
|
60
|
+
is deemed necessary and appropriate to the circumstances. The project team is
|
61
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
62
|
+
Further details of specific enforcement policies may be posted separately.
|
63
|
+
|
64
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good
|
65
|
+
faith may face temporary or permanent repercussions as determined by other
|
66
|
+
members of the project's leadership.
|
67
|
+
|
68
|
+
## Attribution
|
69
|
+
|
70
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
71
|
+
available at [https://contributor-covenant.org/version/1/4][version]
|
72
|
+
|
73
|
+
[homepage]: https://contributor-covenant.org
|
74
|
+
[version]: https://contributor-covenant.org/version/1/4/
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Tom Hulihan
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,815 @@
|
|
1
|
+
# sums_up
|
2
|
+
|
3
|
+
Sum types for Ruby with zero runtime dependencies. Inspired by [hojberg/sums-up](https://github.com/hojberg/sums-up).
|
4
|
+
|
5
|
+
[![Build Status](https://travis-ci.org/nahiluhmot/sums_up.svg?branch=master)](https://travis-ci.org/nahiluhmot/sums_up)
|
6
|
+
|
7
|
+
* [What is a Sum Type?](#what-is-a-sum-type)
|
8
|
+
* [Quick Start](#quick-start)
|
9
|
+
* [Defining Sum Types](#defining-sum-types)
|
10
|
+
* [Predicates](#predicates)
|
11
|
+
* [Pattern Matching with Hashes](#pattern-matching-with-hashes)
|
12
|
+
* [Pattern Matching with Blocks](#pattern-matching-with-blocks)
|
13
|
+
* [Methods on Sum Types](#methods-on-sum-types)
|
14
|
+
* [Variant Instance Methods](#variant-instance-methods)
|
15
|
+
* [A Note on Mutability](#a-note-on-mutability)
|
16
|
+
* [Maybes](#maybes)
|
17
|
+
* [Results](#results)
|
18
|
+
* [Why?](#why)
|
19
|
+
* [Development](#development)
|
20
|
+
* [Contributing](#contributing)
|
21
|
+
* [License](#license)
|
22
|
+
* [Code of Conduct](#code-of-conduct)
|
23
|
+
|
24
|
+
## What is a Sum Type?
|
25
|
+
|
26
|
+
Sum types are data structures with multiple variants.
|
27
|
+
Ruby does not have sum types, but many concepts in the language (like booleans, integers, errors, state machines, etc.) can be described using sum types.
|
28
|
+
Sum types are not limited to those use-cases, however, and are a powerful tool for modeling domain-specific data as well.
|
29
|
+
|
30
|
+
This README uses non-generalized examples of sum types to help build an intuition for when they might be useful.
|
31
|
+
To learn more about sum types, I recommend watching [Philip Wadler's Category Theory for the Working Hacker](https://www.youtube.com/watch?v=V10hzjgoklA) and checking out [Elm's Custom Types](https://guide.elm-lang.org/types/custom_types.html), [Haskell's Sum Types](https://www.schoolofhaskell.com/school/to-infinity-and-beyond/pick-of-the-week/sum-types), [Rust's Enums](https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html), and the [Wikipedia article on Algebraic data types](https://en.wikipedia.org/wiki/Algebraic_data_type).
|
32
|
+
|
33
|
+
## Quick Start
|
34
|
+
|
35
|
+
Define a sum type:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
Direction = SumsUp.define(:north, :south, :east, :west)
|
39
|
+
# => Direction
|
40
|
+
|
41
|
+
Direction.north
|
42
|
+
# => #<variant Direction::North>
|
43
|
+
|
44
|
+
Direction.south
|
45
|
+
# => #<variant Direction::South>
|
46
|
+
|
47
|
+
Direction.east
|
48
|
+
# => #<variant Direction::East>
|
49
|
+
|
50
|
+
Direction.west
|
51
|
+
# => #<variant Direction::West>
|
52
|
+
```
|
53
|
+
|
54
|
+
Use predicates to distinguish between variants:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
def latitudinal?(direction)
|
58
|
+
direction.north? ||
|
59
|
+
direction.south?
|
60
|
+
end
|
61
|
+
|
62
|
+
latitudinal?(Direction.south)
|
63
|
+
# => true
|
64
|
+
|
65
|
+
latitudinal?(Direction.west)
|
66
|
+
# => false
|
67
|
+
```
|
68
|
+
|
69
|
+
Call `#match` to categorically handle each variant by name:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
def turn_clockwise(direction)
|
73
|
+
direction.match do |m|
|
74
|
+
m.north { Direction.east }
|
75
|
+
m.south { Direction.west }
|
76
|
+
m.east { Direction.south }
|
77
|
+
m.west { Direction.north }
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
turn_clockwise(Direction.north)
|
82
|
+
# => #<variant Direction::East>
|
83
|
+
|
84
|
+
turn_clockwise(turn_clockwise(Direction.north))
|
85
|
+
# => #<variant Direction::South>
|
86
|
+
```
|
87
|
+
|
88
|
+
## Defining Sum Types
|
89
|
+
|
90
|
+
Imagine we're writing software for a coffee shop.
|
91
|
+
The menu might look something like this:
|
92
|
+
|
93
|
+
| Item | Small | Large |
|
94
|
+
|----------------------|-------|-------|
|
95
|
+
| Water | Free | |
|
96
|
+
| Lemonade | $3.50 | $4.50 |
|
97
|
+
| Coffee (Hot or Iced) | $2.95 | $3.95 |
|
98
|
+
|
99
|
+
To model the menu using sum types, let's start out with some simple enumerations:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
Size = SumsUp.define(:small, :large)
|
103
|
+
# => Size
|
104
|
+
|
105
|
+
Temperature = SumsUp.define(:hot, :iced)
|
106
|
+
# => Temperature
|
107
|
+
|
108
|
+
Size.small
|
109
|
+
# => #<variant Size::Small>
|
110
|
+
|
111
|
+
Size.large
|
112
|
+
# => #<variant Size::Large>
|
113
|
+
|
114
|
+
Temperature.hot
|
115
|
+
# => #<variant Temperature::Hot>
|
116
|
+
|
117
|
+
Temperature.iced
|
118
|
+
# => #<variant Temperature::Iced>
|
119
|
+
```
|
120
|
+
|
121
|
+
Enumerations work well for `Size` and `Temperature`, but defining a `Drink` type will be a bit more work.
|
122
|
+
There are multiple kinds of drinks (water, lemonade, and coffee), each of which has a varying set of attributes (some drinks are available in multiple sizes, coffee can be served hot or iced).
|
123
|
+
|
124
|
+
To describe these relationships, let's define a sum type with variants who have members.
|
125
|
+
In the below example, `Drink.water` has no members, `Drink.lemonade` has a `size`, and `Drink.coffee` has a `size` and `temperature`:
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
Drink = SumsUp.define(
|
129
|
+
:water,
|
130
|
+
lemonade: :size,
|
131
|
+
coffee: [:size, :temperature]
|
132
|
+
)
|
133
|
+
# => Drink
|
134
|
+
|
135
|
+
Drink.water
|
136
|
+
# => #<variant Drink::Water>
|
137
|
+
|
138
|
+
lemonade = Drink.lemonade(Size.small)
|
139
|
+
# => #<variant Drink::Lemonade size=#<variant Size::Small>>
|
140
|
+
|
141
|
+
lemonade.size
|
142
|
+
# => #<variant Size::Small>
|
143
|
+
|
144
|
+
coffee = Drink.coffee(Size.large, Temperature.iced)
|
145
|
+
# => #<variant Drink::Coffe size=#<variant Size::Large> temperature=#<variant Temperature::Iced>>
|
146
|
+
|
147
|
+
coffee.size
|
148
|
+
# => #<variant Size::Large>
|
149
|
+
|
150
|
+
coffee.temperature
|
151
|
+
# => #<variant Temperature::Iced>
|
152
|
+
|
153
|
+
# Raises because only coffee and lemonade have a size.
|
154
|
+
Drink.water.size
|
155
|
+
# => NoMethodError: undefined method `size' for #<variant Drink::Water>
|
156
|
+
```
|
157
|
+
|
158
|
+
## Predicates
|
159
|
+
|
160
|
+
Predicates are defined for each variant of a sum type:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
Size.large.large?
|
164
|
+
# => true
|
165
|
+
|
166
|
+
Temperature.hot.iced?
|
167
|
+
# => false
|
168
|
+
|
169
|
+
Temperature.iced.iced?
|
170
|
+
# => true
|
171
|
+
|
172
|
+
Drink.water.coffee?
|
173
|
+
# => false
|
174
|
+
|
175
|
+
# Raises because Temperature only has `#hot?` and `#iced?` predicates.
|
176
|
+
Temperature.hot.water?
|
177
|
+
# => NoMethodError: undefined method `water?' for #<variant Temperature::Hot>
|
178
|
+
```
|
179
|
+
|
180
|
+
We can use these to write a function which returns the `Temperature` for a given `Drink`.
|
181
|
+
`Drink.coffee` is the only variant which has an explicit `temperature` attribute, but we know that both `Drink.water` and `Drink.lemonade` are only served iced.
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
def drink_temperature(drink)
|
185
|
+
if drink.coffee?
|
186
|
+
drink.temperature
|
187
|
+
else
|
188
|
+
Temperature.iced
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
drink_temperature(Drink.lemonade(Size.large))
|
193
|
+
# => #<variant Temperature::Iced>
|
194
|
+
|
195
|
+
drink_temperature(Drink.coffee(Size.small, Temperature.hot))
|
196
|
+
# => #<variant Temperature::Hot>
|
197
|
+
```
|
198
|
+
|
199
|
+
## Pattern Matching with Hashes
|
200
|
+
|
201
|
+
Another way to distinguish sum type variants is pattern matching.
|
202
|
+
We can use pattern matching with Hashes to define formatters for `Size` and `Temperature`:
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
def format_size(size)
|
206
|
+
size.match(small: 'Small', large: 'Large')
|
207
|
+
end
|
208
|
+
|
209
|
+
def format_temperature(temperature)
|
210
|
+
temperature.match(hot: 'Hot', iced: 'Iced')
|
211
|
+
end
|
212
|
+
|
213
|
+
format_size(Size.large)
|
214
|
+
# => 'Large'
|
215
|
+
|
216
|
+
format_temperature(Temperature.iced)
|
217
|
+
# => 'Iced'
|
218
|
+
```
|
219
|
+
|
220
|
+
In some cases, it can be convenient to match against some variants and use a wildcard for the rest:
|
221
|
+
|
222
|
+
```ruby
|
223
|
+
def free?(drink)
|
224
|
+
drink.match(water: true, _: false)
|
225
|
+
end
|
226
|
+
|
227
|
+
free?(Drink.water)
|
228
|
+
# => true
|
229
|
+
|
230
|
+
free?(Drink.lemonade(Size.large))
|
231
|
+
# => false
|
232
|
+
```
|
233
|
+
|
234
|
+
`#match` will raise if any variants are left unmatched.
|
235
|
+
The following method does not handle `Drink.water` and will raise whenever any drink is provided:
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
def added_sugar?(drink)
|
239
|
+
drink.match(lemonade: true, coffee: false)
|
240
|
+
end
|
241
|
+
|
242
|
+
# Raises because water is not matched.
|
243
|
+
added_sugar?(Drink.water)
|
244
|
+
# => SumsUp::UnmatchedVariantError: Did not match the following variants: water
|
245
|
+
|
246
|
+
# Raises because water is not matched, even though a lemonade is getting passed in.
|
247
|
+
added_sugar?(Drink.lemonade(Size.large))
|
248
|
+
# => SumsUp::UnmatchedVariantError: Did not match the following variants: water
|
249
|
+
```
|
250
|
+
|
251
|
+
## Pattern Matching with Blocks
|
252
|
+
|
253
|
+
Matching against the variant name is often not enough, we need to be able to use the variant's members as well.
|
254
|
+
For these use-cases, `#match` accepts a block.
|
255
|
+
For variants with members, each member is yielded to the `#match` block:
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
def format_drink(drink)
|
259
|
+
drink.match do |m|
|
260
|
+
m.water { 'Water' }
|
261
|
+
m.lemonade { |size| "#{format_size(size)} Lemonade" }
|
262
|
+
m.coffee do |size, temperature|
|
263
|
+
"#{format_size(size)} #{format_temperature(temperature)} Coffee"
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
format_drink(Drink.water)
|
269
|
+
# => 'Water'
|
270
|
+
|
271
|
+
format_drink(Drink.lemonade(Size.small))
|
272
|
+
# => 'Small Lemonade'
|
273
|
+
|
274
|
+
format_drink(Drink.coffee(Size.large, Temperature.iced))
|
275
|
+
# => 'Large Iced Coffee'
|
276
|
+
```
|
277
|
+
|
278
|
+
Like Hash-based pattern matching, Block-based pattern matching can use wildcards as well.
|
279
|
+
The below example redefines `drink_temperature` using pattern matching:
|
280
|
+
|
281
|
+
```ruby
|
282
|
+
def drink_temperature(drink)
|
283
|
+
drink.match do |m|
|
284
|
+
m.coffee { |_size, temperature| temperature }
|
285
|
+
m._ { Temperature.iced }
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
drink_temperature(Drink.water)
|
290
|
+
# => #<variant Temperature::Iced>
|
291
|
+
```
|
292
|
+
|
293
|
+
Note: if using the wildcard pattern matcher (`_`), it must come after the explicit variant matches.
|
294
|
+
|
295
|
+
The match syntax also supports passing values directly to the matcher, as opposed to passing a block:
|
296
|
+
|
297
|
+
```ruby
|
298
|
+
# Waters are always small, other drinks use their specified size.
|
299
|
+
def drink_size(drink)
|
300
|
+
drink.match do |m|
|
301
|
+
m.water Size.small
|
302
|
+
m.lemonade { |size| size }
|
303
|
+
m.temperature { |size, _temperature| size }
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
drink_size(Drink.water)
|
308
|
+
# => #<variant Size::Small>
|
309
|
+
|
310
|
+
drink_size(Drink.lemonade(Size.small))
|
311
|
+
# => #<variant Size::Small>
|
312
|
+
```
|
313
|
+
|
314
|
+
This syntax will also raise if not all variants of a type are matched:
|
315
|
+
|
316
|
+
```ruby
|
317
|
+
def drink_price(drink)
|
318
|
+
drink.match do |m|
|
319
|
+
m.water 0
|
320
|
+
m.lemonade { |size| size.match(small: 350, large: 450) }
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# Raises because coffee is not matched.
|
325
|
+
drink_price(Drink.coffee(Size.large, Temperature.hot))
|
326
|
+
# => SumsUp::UnmatchedVariantError: Did not match the following variants: coffee
|
327
|
+
|
328
|
+
# Raises because coffee is not matched, even though a water is getting passed in.
|
329
|
+
drink_price(Drink.water)
|
330
|
+
# => SumsUp::UnmatchedVariantError: Did not match the following variants: coffee
|
331
|
+
```
|
332
|
+
|
333
|
+
## Methods on Sum Types
|
334
|
+
|
335
|
+
When defining a sum type, we can add methods to it by passing a block to `SumsUp.define`:
|
336
|
+
|
337
|
+
```ruby
|
338
|
+
Drink = SumsUp.define(:water, lemonade: :size, coffee: [:temperature, :size]) do
|
339
|
+
def price_in_cents
|
340
|
+
match do |m|
|
341
|
+
m.water 0
|
342
|
+
m.lemonade { |size| size.match(small: 350, large: 450) }
|
343
|
+
m.coffee { |size, _temperature| size.match(small: 295, large: 395) }
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
Drink.water.price_in_cents
|
349
|
+
# => 0
|
350
|
+
|
351
|
+
Drink.lemonade(Size.small).price_in_cents
|
352
|
+
# => 350
|
353
|
+
|
354
|
+
Drink.coffee(Size.large, Temperature.hot).price_in_cents
|
355
|
+
# => 395
|
356
|
+
```
|
357
|
+
|
358
|
+
This syntax also supports class methods and constants:
|
359
|
+
|
360
|
+
```ruby
|
361
|
+
Size = SumsUp.define(:small, :large) do
|
362
|
+
SMALL_STRING = 'Small'.freeze
|
363
|
+
LARGE_STRING = 'Large'.freeze
|
364
|
+
|
365
|
+
def self.parse(str)
|
366
|
+
case str
|
367
|
+
when SMALL_STRING
|
368
|
+
small
|
369
|
+
when LARGE_STRING
|
370
|
+
large
|
371
|
+
else
|
372
|
+
raise ArgumentError, "Invalid size: #{str}"
|
373
|
+
end
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
Size::SMALL_STRING
|
378
|
+
# => 'Small'
|
379
|
+
|
380
|
+
Size::LARGE_STRING
|
381
|
+
# => 'Large'
|
382
|
+
|
383
|
+
Size.parse('Small')
|
384
|
+
# => #<variant Size::Small>
|
385
|
+
|
386
|
+
Size.parse('Trenta')
|
387
|
+
# => ArgumentError: Invalid size: Trenta
|
388
|
+
```
|
389
|
+
|
390
|
+
## Variant Instance Methods
|
391
|
+
|
392
|
+
In addition to user-defined methods, `#inspect`, and `#==`, variant instances come with convenience methods for accessing and updating members.
|
393
|
+
|
394
|
+
### Getters
|
395
|
+
|
396
|
+
Fetch a variant's members by name:
|
397
|
+
|
398
|
+
```ruby
|
399
|
+
coffee = Drink.coffee(Size.small, Temperature.hot)
|
400
|
+
coffee.size
|
401
|
+
# => #<variant Size::Small>
|
402
|
+
|
403
|
+
coffee.temperature
|
404
|
+
# => #<variant Temperature::Hot>
|
405
|
+
|
406
|
+
lemonade = Drink.lemonade(Size.large)
|
407
|
+
lemonade.size
|
408
|
+
# => #<variant Size::Large>
|
409
|
+
|
410
|
+
# Lemonade does not have a 'temperature' member.
|
411
|
+
lemonade.temperature
|
412
|
+
# => NoMethodError: undefined method `temperature' for #<variant Drink::Lemonade size=#<variant Size::Large>>
|
413
|
+
```
|
414
|
+
|
415
|
+
Another way to access members is `#[]`:
|
416
|
+
|
417
|
+
```ruby
|
418
|
+
coffee = Drink.coffee(Size.small, Temperature.iced)
|
419
|
+
coffee[:size]
|
420
|
+
# => #<variant Size::Small>
|
421
|
+
|
422
|
+
# #[] works with Strings as well.
|
423
|
+
lemonade = Drink.lemonade(Size.large)
|
424
|
+
lemonade['size']
|
425
|
+
# => #<variant Size::Large>
|
426
|
+
|
427
|
+
# #[] will raise given an invalid member.
|
428
|
+
lemonade[:temperature]
|
429
|
+
# => NameError: No member 'temperature' in variant lemonade.
|
430
|
+
```
|
431
|
+
|
432
|
+
### Setters
|
433
|
+
|
434
|
+
Members may also be updated by name:
|
435
|
+
|
436
|
+
```ruby
|
437
|
+
coffee = Drink.coffee(Size.small, Temperature.hot)
|
438
|
+
coffee.temperature = Temperature.iced # Oh, sorry, could you make that iced?
|
439
|
+
coffee.temperature
|
440
|
+
# => #<variant Temperature::Iced>
|
441
|
+
```
|
442
|
+
|
443
|
+
`#[]=` can also update a member:
|
444
|
+
|
445
|
+
```ruby
|
446
|
+
lemonade = Drink.lemonade(Size.large)
|
447
|
+
lemonade['size'] = Size.small # Oh, a large is 32oz?
|
448
|
+
lemonade.size
|
449
|
+
# => #<variant Size::Small>
|
450
|
+
|
451
|
+
lemonade[:temperature] = Temperature.hot # Sorry, we don't do that here.
|
452
|
+
# => NameError: No member 'temperature' in variant lemonade.
|
453
|
+
```
|
454
|
+
|
455
|
+
### `#attributes`
|
456
|
+
|
457
|
+
Get a variant's members as a `Hash`:
|
458
|
+
|
459
|
+
```ruby
|
460
|
+
Drink.water.attributes
|
461
|
+
# => {}
|
462
|
+
|
463
|
+
Drink.lemonade(Size.small).attributes
|
464
|
+
# => { size: #<variant Size::Small> }
|
465
|
+
|
466
|
+
Drink.coffee(Size.large, Temperataure.iced).attributes
|
467
|
+
# => { size: #<variant Size::Large> , temperature: #<variant Temperature::Iced> }
|
468
|
+
```
|
469
|
+
|
470
|
+
### `#to_h`
|
471
|
+
|
472
|
+
Return the variant as a `Hash`:
|
473
|
+
|
474
|
+
```ruby
|
475
|
+
Drink.water.to_h
|
476
|
+
# => { water: {} }
|
477
|
+
|
478
|
+
Drink.lemonade(Size.small).to_h
|
479
|
+
# => { lemonade: { size: #<variant Size::Small> } }
|
480
|
+
|
481
|
+
Drink.coffee(Size.large, Temperataure.iced).to_h
|
482
|
+
# => { coffee: { size: #<variant Size::Large> , temperature: #<variant Temperature::Iced> } }
|
483
|
+
```
|
484
|
+
|
485
|
+
Use `include_root: false` to make this method behave like `#attributes`:
|
486
|
+
|
487
|
+
```ruby
|
488
|
+
Drink.water.to_h(include_root: false)
|
489
|
+
# => {}
|
490
|
+
|
491
|
+
Drink.lemonade(Size.small).to_h(include_root: false)
|
492
|
+
# => { size: #<variant Size::Small> }
|
493
|
+
|
494
|
+
Drink.coffee(Size.large, Temperataure.iced).to_h(include_root: false)
|
495
|
+
# => { size: #<variant Size::Large>, temperature: #<variant Temperature::Iced> }
|
496
|
+
```
|
497
|
+
|
498
|
+
### `#members`
|
499
|
+
|
500
|
+
Get a variant's members in the order they were in when passed into the initializer:
|
501
|
+
|
502
|
+
```ruby
|
503
|
+
Drink.water.members
|
504
|
+
# => []
|
505
|
+
|
506
|
+
Drink.lemonade(Size.small).members
|
507
|
+
# => [#<variant Size::Small>]
|
508
|
+
|
509
|
+
Drink.coffee(Size.large, Temperataure.iced).members
|
510
|
+
# => [#<variant Size::Large>, #<variant Temperature::Iced>]
|
511
|
+
```
|
512
|
+
|
513
|
+
## A Note on Mutability
|
514
|
+
|
515
|
+
All variants without members are memoized and frozen by default.
|
516
|
+
In our running example calling `Size.small`, `Size.large`, `Temperature.hot`, `Temperature.iced`, and `Drink.water` would all return memoized and frozen objects, but `Drink.lemonade(size)` and `Drink.coffee(size, temperature)` would not.
|
517
|
+
This helps reduce the memory footprint of the gem, but makes it so that we cannot write to instance variables within the class.
|
518
|
+
|
519
|
+
Let's say that we wanted to memoize the result of `#price_in_cents` like so:
|
520
|
+
|
521
|
+
```ruby
|
522
|
+
Drink = SumsUp.define(:water, lemonade: :size, coffee: [:temperature, :size]) do
|
523
|
+
def price_in_cents
|
524
|
+
@price_in_cents ||= match do |m|
|
525
|
+
m.water 0
|
526
|
+
m.lemonade { |size| size.match(small: 350, large: 450) }
|
527
|
+
m.coffee { |size, _temperature| size.match(small: 295, large: 395) }
|
528
|
+
end
|
529
|
+
end
|
530
|
+
end
|
531
|
+
```
|
532
|
+
|
533
|
+
The `Drink.lemonade` and `Drink.coffee` variants would be unaffected because they are not frozen:
|
534
|
+
|
535
|
+
```ruby
|
536
|
+
Drink.lemonade(Size.large).price_in_cents
|
537
|
+
# => 450
|
538
|
+
|
539
|
+
Drink.coffee(Size.small, Temperature.hot).price_in_cents
|
540
|
+
# => 295
|
541
|
+
```
|
542
|
+
|
543
|
+
However, `Drink.water` will raise because it is frozen:
|
544
|
+
|
545
|
+
```ruby
|
546
|
+
Drink.water.price_in_cents
|
547
|
+
# => RuntimeError: can't modify frozen Drink::Water
|
548
|
+
```
|
549
|
+
|
550
|
+
In general, it's better to find solutions which don't require state to be tracked within data types, but if mutability is absolutely required, we can work around this by passing `memo: false` to the memberless variant's initializer:
|
551
|
+
|
552
|
+
```ruby
|
553
|
+
Drink.water(memo: false).price_in_cents
|
554
|
+
# => 0
|
555
|
+
```
|
556
|
+
|
557
|
+
This will work with any memberless variant:
|
558
|
+
|
559
|
+
```ruby
|
560
|
+
Size.small(memo: false)
|
561
|
+
# => #<variant Size::Small>
|
562
|
+
|
563
|
+
Size.large(memo: false)
|
564
|
+
# => #<variant Size::Large>
|
565
|
+
|
566
|
+
Temperature.hot(memo: false)
|
567
|
+
# => #<variant Temperature::Hot>
|
568
|
+
|
569
|
+
Temperature.iced(memo: false)
|
570
|
+
# => #<variant Temperature::Iced>
|
571
|
+
```
|
572
|
+
|
573
|
+
## Maybes
|
574
|
+
|
575
|
+
`SumsUp::Maybe` represents a value which may or may not be present.
|
576
|
+
|
577
|
+
Variants:
|
578
|
+
|
579
|
+
```ruby
|
580
|
+
SumsUp::Maybe.nothing
|
581
|
+
# => #<variant SumsUp::Maybe::Nothing>
|
582
|
+
|
583
|
+
SumsUp::Maybe.just(1)
|
584
|
+
# => #<variant SumsUp::Maybe::Just value=1>
|
585
|
+
```
|
586
|
+
|
587
|
+
Predicates:
|
588
|
+
|
589
|
+
```ruby
|
590
|
+
SumsUp::Maybe.nothing.nothing?
|
591
|
+
# => true
|
592
|
+
|
593
|
+
SumsUp::Maybe.nothing.just?
|
594
|
+
# => false
|
595
|
+
|
596
|
+
SumsUp::Maybe.just(1).nothing?
|
597
|
+
# => false
|
598
|
+
|
599
|
+
SumsUp::Maybe.just(2).just?
|
600
|
+
# => true
|
601
|
+
```
|
602
|
+
|
603
|
+
Pattern matching:
|
604
|
+
|
605
|
+
```ruby
|
606
|
+
def maybe_to_int(maybe)
|
607
|
+
maybe.match do |m|
|
608
|
+
m.nothing 0
|
609
|
+
m.just { |num| num }
|
610
|
+
end
|
611
|
+
end
|
612
|
+
|
613
|
+
maybe_to_int(SumsUp::Maybe.nothing)
|
614
|
+
# => 0
|
615
|
+
|
616
|
+
maybe_to_int(SumsUp::Maybe.just(1))
|
617
|
+
# => 1
|
618
|
+
```
|
619
|
+
|
620
|
+
`SumsUp::Maybe.of` builds a `SumsUp::Maybe` from a value which may be `nil`:
|
621
|
+
|
622
|
+
```ruby
|
623
|
+
SumsUp::Maybe.of(nil)
|
624
|
+
# => #<variant SumsUp::Maybe::Nothing>
|
625
|
+
|
626
|
+
SumsUp::Maybe.of('cat')
|
627
|
+
# => #<variant SumsUp::Maybe::Just value="cat">
|
628
|
+
|
629
|
+
SumsUp::Maybe.of(false)
|
630
|
+
# => #<variant SumsUp::Maybe::Just value=false>
|
631
|
+
```
|
632
|
+
|
633
|
+
`SumsUp::Maybe#map` applies a function to the value if it's present:
|
634
|
+
|
635
|
+
```ruby
|
636
|
+
SumsUp::Maybe.nothing.map { |x| x + 1 }
|
637
|
+
# => #<variant SumsUp::Maybe::Nothing>
|
638
|
+
|
639
|
+
SumsUp::Maybe.just(3).map { |x| x + 1 }
|
640
|
+
# => #<variant SumsUp::Maybe::Just value=4>
|
641
|
+
```
|
642
|
+
|
643
|
+
`SumsUp::Maybe#or_else` returns the wrapped value, or a default if it's not present:
|
644
|
+
|
645
|
+
```ruby
|
646
|
+
SumsUp::Maybe.nothing.or_else(1)
|
647
|
+
# => 1
|
648
|
+
|
649
|
+
SumsUp::Maybe.nothing.or_else { 2 }
|
650
|
+
# => 2
|
651
|
+
|
652
|
+
SumsUp::Maybe.just(3).or_else(4)
|
653
|
+
# => 3
|
654
|
+
|
655
|
+
SumsUp::Maybe.just(4).or_else { 5 }
|
656
|
+
# => 4
|
657
|
+
```
|
658
|
+
|
659
|
+
## Results
|
660
|
+
|
661
|
+
`SumsUp::Result` represents a successful result or an error.
|
662
|
+
|
663
|
+
Variants:
|
664
|
+
|
665
|
+
```ruby
|
666
|
+
SumsUp::Result.failure('update failed')
|
667
|
+
# => #<variant SumsUp::Maybe::Failure error="update failed">
|
668
|
+
|
669
|
+
SumsUp::Maybe.success('request payload')
|
670
|
+
# => #<variant SumsUp::Maybe::Just value="request payload">
|
671
|
+
```
|
672
|
+
|
673
|
+
Predicates:
|
674
|
+
|
675
|
+
```ruby
|
676
|
+
SumsUp::Result.failure(false).failure?
|
677
|
+
# => true
|
678
|
+
|
679
|
+
SumsUp::Result.failure(0).success?
|
680
|
+
# => false
|
681
|
+
|
682
|
+
SumsUp::Result.success(true).failure?
|
683
|
+
# => false
|
684
|
+
|
685
|
+
SumsUp::Result.success(1).success?
|
686
|
+
# => true
|
687
|
+
```
|
688
|
+
|
689
|
+
Pattern matching:
|
690
|
+
|
691
|
+
```ruby
|
692
|
+
def flip_result(result)
|
693
|
+
result.match do |m|
|
694
|
+
m.failure { |error| SumsUp::Result.success(error) }
|
695
|
+
m.success { |value| SumsUp::Result.failure(value) }
|
696
|
+
end
|
697
|
+
end
|
698
|
+
|
699
|
+
flip_result(SumsUp::Result.success('yay'))
|
700
|
+
# => #<variant SumsUp::Result::Failure error="yay">
|
701
|
+
|
702
|
+
flip_result(flip_result(SumsUp::Result.failure('boo')))
|
703
|
+
# => #<variant SumsUp::Result::Failure error="boo">
|
704
|
+
```
|
705
|
+
|
706
|
+
`SumsUp::Result.from_block` converts a block which may raise into a `SumsUp::Result`:
|
707
|
+
|
708
|
+
```ruby
|
709
|
+
SumsUp::Result.from_block { raise 'unexpected error' }
|
710
|
+
# => #<variant SumsUp::Result::Failure error=#<RuntimeError: unexpected error>>
|
711
|
+
|
712
|
+
SumsUp::Result.from_block { 'good result' }
|
713
|
+
# => #<variant SumsUp::Result::Success value="good result">
|
714
|
+
```
|
715
|
+
|
716
|
+
`SumsUp::Result#map` applies a function to the successful values:
|
717
|
+
|
718
|
+
```ruby
|
719
|
+
SumsUp::Result.failure('sorry kid').map { |x| x + ', nothing personal' }
|
720
|
+
# => #<variant SumsUp::Result::Failure error="sorry kid">
|
721
|
+
|
722
|
+
SumsUp::Result.success(10).map { |x| x * 2 }
|
723
|
+
# => #<variant SumsUp::Result::Success value=20>
|
724
|
+
```
|
725
|
+
|
726
|
+
`SumsUp::Result#map_failure` applies a function to the failure errors:
|
727
|
+
|
728
|
+
```ruby
|
729
|
+
SumsUp::Result.failure('sorry kid').map_failure { |x| x + ', nothing personal' }
|
730
|
+
# => #<variant SumsUp::Result::Failure error="sorry kid, nothing personal">
|
731
|
+
|
732
|
+
SumsUp::Result.success(10).map_failure { |x| x * 2 }
|
733
|
+
# => #<variant SumsUp::Result::Success value=10>
|
734
|
+
```
|
735
|
+
|
736
|
+
## Why?
|
737
|
+
|
738
|
+
Some of these examples may seem odd if you're not familiar with sum types.
|
739
|
+
If we were instead using the tools provided by Ruby, we might use a boolean to determine whether a given drink is a small or large, hot or cold.
|
740
|
+
This would work, of course, so why use sum types?
|
741
|
+
|
742
|
+
Let's illustrate by defining `OtherDrink` using a `Struct` with booleans for `is_hot` and `is_large`
|
743
|
+
|
744
|
+
```ruby
|
745
|
+
OtherDrink = Struct.new(:type, :is_hot, :is_large) do
|
746
|
+
private_class_method(:new)
|
747
|
+
|
748
|
+
def self.water
|
749
|
+
new(:water, false, false)
|
750
|
+
end
|
751
|
+
|
752
|
+
def self.lemonade(is_large)
|
753
|
+
new(:lemonade, false, is_large)
|
754
|
+
end
|
755
|
+
|
756
|
+
def self.coffee(is_hot, is_large)
|
757
|
+
new(:coffee, is_hot, is_large)
|
758
|
+
end
|
759
|
+
end
|
760
|
+
|
761
|
+
OtherDrink.water
|
762
|
+
# => #<struct OtherDrink type=:water, is_hot=false, is_large=false>
|
763
|
+
|
764
|
+
OtherDrink.lemonade(true)
|
765
|
+
# => #<struct OtherDrink type=:lemonade, is_hot=false, is_large=true>
|
766
|
+
|
767
|
+
OtherDrink.coffee(true, false)
|
768
|
+
# => #<struct OtherDrink type=:water, is_hot=true, is_large=false>
|
769
|
+
```
|
770
|
+
|
771
|
+
`OtherDrink` can do all of the things that `Drink` can, but its API is less descriptive.
|
772
|
+
For example, to represent a small hot coffee using `OtherDrink`, we would call `OtherDrink.coffee(true, false)`.
|
773
|
+
In the `sums_up`-defined `Drink`, we'd instead call `Drink.coffee(Size.small, Temperature.hot)`.
|
774
|
+
This may seem a bit contrived, but using a sum type instead of a boolean can help make our code more declarative and self-documenting.
|
775
|
+
|
776
|
+
Sum types can also provide extensibility when project requirements change.
|
777
|
+
If our example cafe started carrying medium coffees and lemonades, we only need update our `Size` type to accomodate that:
|
778
|
+
|
779
|
+
```ruby
|
780
|
+
Size = SumsUp.define(:small, :medium, :large)
|
781
|
+
|
782
|
+
Drink.coffee(Size.medium, Temperature.iced)
|
783
|
+
# => #<variant Drink::Coffee size=#<variant Size::Medium> temperature=#<variant Temperature::Iced>>
|
784
|
+
```
|
785
|
+
|
786
|
+
How would we handle this if we were using `OtherDrink`?
|
787
|
+
A boolean is no longer suitable given that we need to track three different possible options, so we would probably end up using symbols like `:small`, `:medium`, and `:large`.
|
788
|
+
This will work, but refactoring will likely be more difficult.
|
789
|
+
|
790
|
+
With `Size`, after adding `Size.medium`, we find our `Size#match` calls, ensure that we're handling `Size.medium`, and we're done.
|
791
|
+
With `OtherDrink`'s `:small`, `:medium`, and `:large` symbols, we would need to refactor the code which uses `OtherDrink.is_large` to instead match on symbols, and we would also introduce the possibility that a drink's size is invalid.
|
792
|
+
This can lead to us writing a lot of checks for invalid data which may or may not be necessary.
|
793
|
+
Sum types will do this for you; there's no way to make an invalid `Size`, so we know that our `#match` calls are categorically handling all cases.
|
794
|
+
|
795
|
+
## Development
|
796
|
+
|
797
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
798
|
+
Then, run `bundle exec rake spec` to run the tests.
|
799
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
800
|
+
|
801
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
802
|
+
To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
803
|
+
|
804
|
+
## Contributing
|
805
|
+
|
806
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/nahiluhmot/sums_up.
|
807
|
+
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nahiluhmot/sums_up/blob/master/CODE_OF_CONDUCT.md).
|
808
|
+
|
809
|
+
## License
|
810
|
+
|
811
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
812
|
+
|
813
|
+
## Code of Conduct
|
814
|
+
|
815
|
+
Everyone interacting in the SumsUp projects codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/nahiluhmot/sums_up/blob/master/CODE_OF_CONDUCT.md).
|