Transcript

tl;dr

  1. We have a new gem called Put
  2. I made a screencast demoing sorting complex objects by multiple conditions, both in pure Ruby and with Put
  3. Here’s the example code
  4. After seeing Put’s README, I was asked to refrain from any “put” wordplay in this post

Not too long; did read

I have a confession to make: I’ve been programming Ruby since 2004 and I still get tripped up whenever I encounter the <=> spaceship operator. As recently as this week, I’ve caught myself slowly and unconfidently working out the rules in my head: “okay, if the receiver is comparably greater than the argument, it should return -1, right?

To avoid this confusion, many Rubyists reach for the Enumerable#sort_by method at the first sign of trouble. It lets us pass a block that reduces our complex objects into simpler ones Ruby can sort for us (like String and Numeric).

For example, we could sort people by age ascending like this:

people.sort_by { |person| person.age }

Or, if we need to sort by age descending, we could make the ages negative:

people.sort_by { |person| -1 * person.age }

And if we need to add a secondary sorting condition—say, people of the same age should be sorted by name ascending—we can return an array in our block that returns the array in priority order, relying on the fact that Ruby sorts arrays stepwise by element:

people.sort_by { |person|
  [
    -1 * person.age,
    person.name
  ]
}

But what if someone’s age or name is nil? Then you’ll need guard clauses to avoid an ArgumentError:

people.sort_by { |person|
  [
    person.age.nil? ? 0 : -1 * person.age,
    person.name || "zzz"
  ]
}

Wow! It didn’t take long for our simple one-off sort to become a bit of a mess. We’d need code comment for these rules to make sense to others.

To make these multi-criteria sorts more expressive, terser, and nil-safe, I wrote a little gem called Put last week that can clean up sort_by blocks:

people.sort_by { |person|
  [
    Put.desc(person.age, nils_first: true),
    Put.asc(person.name)
  ]
}

This is a new pattern to a lot of programmers, so I recorded this screencast building a non-trivial sort_by block in pure Ruby, then translating the same conditions to the new Put API. I hope you’ll check it out! You can find the video’s example code here.

If you enjoy the video, we’d love if you subscribed to our fledgling YouTube channel and our e-mail newsletter to stay in touch with what I and my fellow Double Agents are working on! 🕵️

00:00
(upbeat electronic chimes)
00:03
- Hello, I'm here to talk about my new gem, Put.
00:07
Now Put helps you sort objects in memory using Ruby
00:10
and it does so following a particular pattern that you may
00:12
or may not be familiar with
00:14
using a numerable dot sort by and returning an array.
00:17
So because that pattern isn't super well known,
00:20
I figured it would make sense to first show an example
00:22
in pure Ruby and then show off why the Put gem
00:25
can help make your code a little bit cleaner
00:27
a little bit safer, and definitely more expressive
00:30
in terms of what you're intending when you're sorting
00:32
by multiple criteria.
00:34
Now, for want of an example,
00:36
I was thinking about how a lot of folks are having to return
00:38
to the office soon,
00:39
not at Test Double, 'cause we're remote first
00:41
but I'm kind of ginning up some empathy to imagine
00:44
that I would not be looking forward
00:45
to returning to break rooms and the particular
00:48
smells that happen when people microwave fish.
00:52
So, as a programmer,
00:54
I'm like a lot of programmers
00:55
whenever I've got a social problem
00:57
and I should have a hard conversation with somebody.
00:59
I'd much rather try to solve that
01:00
with software and technology and you know.
01:03
So instead of actually asking someone
01:05
not to microwave their fish,
01:06
maybe I'd write a program that would build a duty roster
01:09
for everyone taking turns cleaning the break room.
01:11
So we're gonna implement break room sort today.
01:15
And what it is,
01:16
is going to prioritize all of the employees
01:18
in terms of when they should be next responsible
01:21
for cleaning up the break room.
01:22
First of all, you know
01:24
we're gonna sort all the current employees
01:25
to the top and then anyone with mobility impairments
01:29
or an accommodation last.
01:32
Next, anyone who's cleaned the break room
01:35
least recently should be their turn next.
01:38
Or if they've never cleaned it.
01:39
Whoever's microwave fish most recently
01:41
should be the tiebreaker after that
01:44
because they're the problem.
01:46
And next, if that's still a tie,
01:49
if all those conditions are met,
01:51
then we should have the more senior people
01:53
in the organization be responsible for cleaning.
01:56
Servant leadership and all that.
01:57
So CEO cleans the before staff
02:00
and then the last tiebreaker is whoever's located closest
02:03
to the break room using latitude and longitude.
02:06
And so that's our exercise today.
02:11
So let's go ahead and get started.
02:13
We're gonna open up vs code.
02:14
I've already done some of the work here
02:16
to just sort of like flesh things out.
02:18
So we've got users, we've got a break room,
02:21
we've got a even a stub method
02:22
for sorts break room duty roster
02:27
and a sort method.
02:28
You can see that these default (indistinct) here are just
02:30
gonna generate a break room example and user examples.
02:33
So break room has a name, latitude, longitude
02:36
whether it's clean or not.
02:37
And these are just using the faker gem to make up fake ones.
02:40
I'll share all this code later.
02:42
And a user has name, active, the accommodations
02:44
that they have.
02:45
Last clean break room, last microwaved fish,
02:47
level, lat, longitude and so forth.
02:50
All right, so one way we could do this is,
02:53
and one of my favorite ways to do it.
02:55
It's not the fastest way on the planet,
02:56
but we're already in memory with Ruby.
02:58
So presumably we couldn't sort this in a database.
02:59
If you could just do this
03:00
with an order by of course that would be faster.
03:02
But in this case,
03:03
we've got some custom criteria that we wanna search.
03:05
Maybe we don't have a whole lot of employees
03:07
so it's safe to just pull all the users in
03:09
and sort in memory with Ruby.
03:11
And so we're gonna do that using enumerable dot sort by.
03:14
Sort by and user.
03:18
So we take all the users, we get a user
03:21
and then we can sort by any one condition.
03:22
So we could just say,
03:23
user.active and now that would be true and false.
03:28
So we run this file here
03:31
which is gonna call sorts break room duty roster.
03:36
And it calls sort and then describes the first 10 items.
03:40
However, it didn't do that
03:41
because it tried to compare true and false.
03:44
So those are not comparable.
03:45
That means that like a lot of the work here
03:47
is gonna gonna be taking non-comparable things
03:50
like two Boolean values
03:52
and making them comparable like numbers.
03:54
So if they're active then we'll say zero.
03:57
And if they're not active, we'll say one.
03:59
So that the active stuff is lower value and goes above,
04:03
goes first before stuff with a higher value of one.
04:06
So that means the active stuff will be sorted at the top
04:09
and we should be able to see, okay, cool, active stuff.
04:11
Now one of the cool facts of how sorting works
04:13
in Ruby is that arrays are sorted one element at a time.
04:16
So that means that we can actually have multiple conditions.
04:20
We could say first show me all the active users on top
04:24
and then we were talking about mobility accommodations.
04:27
We could say, user.accommodations
04:33
I'm not good at spelling this.
04:35
I wish auto complete saved me.
04:37
Include mobility.
04:40
Same sort of trick here we have to use (indistinct)
04:43
to convert this into something that's comparable.
04:45
We're gonna do the opposite
04:46
'cause we want this to be descending.
04:47
So if you have such an accommodation
04:50
we're gonna say one.
04:53
And if not, then we're gonna say zero.
04:55
So that means that the folks
04:58
with the mobility impairment would not be asked to go
05:00
and clean the break room
05:01
and folks that do,
05:02
they'd do not.
05:06
They'd sort top.
05:07
So it's basically just like fling all this stuff
05:09
to the top and all these people to the bottom
05:10
of this list as as the first couple criteria
05:14
while we get to our other sorting criteria.
05:17
All right?
05:18
So we could run that here
05:19
and we should be able to scan the list
05:22
and not see any mobility accommodations they might have
05:25
like another one there.
05:30
At any given time we can kind of just look
05:31
at like which tiebreaker are we looking at?
05:33
By commenting out the ones above us.
05:36
And yeah it looks good.
05:38
We're not sorting the opposite way.
05:40
All right, the next case
05:42
that we have now this is time based
05:44
is when did they clean the break room?
05:48
Last cleaned break room app.
05:51
It's hard to type and talk at the same time.
05:54
Here we can just sort by the date, right?
05:57
Except we can't do that because we have some nil cases.
06:02
And again, nil is not comparable
06:04
with much of anything by default.
06:05
And so comparison of array with array failed
06:08
this is all served by gives you if any...
06:11
There's a thousand arrays of arrays in here.
06:13
If any single one of them failed
06:14
all you get is argument error.
06:15
It doesn't tell you anything.
06:17
So when you get into the Put gem,
06:19
there's a Put.description or Put.debug,
06:23
sorry method.
06:24
And you can pass it this array of array
06:26
and it'll try to give you some sense
06:27
of where our comparison is breaking down.
06:29
But failing that,
06:30
all we gotta do is we gotta know nils are not okay.
06:33
So we can say "Hey, if it's nil, maybe"
06:38
Oh yeah double.
06:39
Turners are so tricky when you have a predicate method.
06:42
We could say time parse
06:44
give it a long, long ago value like 1999.
06:48
Okay?
06:49
Otherwise we will give you
06:51
the user last cleaned break room at value.
06:55
So that should give us probably a bunch
06:57
of people with nil cleaned break room mats.
07:00
Yeah, at the very top the nil ones.
07:03
There's only a couple and then,
07:06
oh yeah, there's like seven or eight.
07:08
Then yeah, these ones are not very recent.
07:10
They're 2021.
07:11
I think we're only generating dates out a year in a arrears.
07:15
So that's cool.
07:16
And then the next date was user last microwaved fish at.
07:24
Now we can't just do this
07:26
because if it was just the date,
07:29
it would be ascending.
07:30
And so if it's ascending.
07:32
it's gonna actually be the most recent
07:35
fish microwavings would go to the bottom of the list.
07:39
Additionally, we're gonna have some nils in there
07:41
'cause some people will have never microwaved fish.
07:43
And so here we could say,
07:44
"All right, first of all if it's nil,
07:49
so would we put a time in the future?"
07:51
No, we wouldn't do that 'cause like we wanna get
07:53
to something that's like going to ascend in the right order.
07:56
And one way to do that would be to like
07:58
think of the duration,
07:59
like how long has it been since the last microwave fish?
08:01
So we could do that by...
08:04
Just to illustrate time.now minus when you did this.
08:09
Now that would be a duration.
08:13
If I run this though, I'm still gonna have nil.
08:14
So it's gonna be, I can't convert that.
08:16
So instead of a turner,
08:18
another thing I might do is like a short circuit
08:20
or a statement.
08:21
I could just say time.now.
08:25
Of course if I say zero,
08:27
that'll make it very, very low
08:29
which I'll sort it higher.
08:31
Oh goodness.
08:32
If I say zero, that'll make it high.
08:34
Yes.
08:35
So that's what I want I wanna say so.
08:40
To isolate to just this condition
08:41
we can comment on this stuff and then take a look.
08:44
So we're gonna run again.
08:47
Array with array failed again.
08:48
Oh no, what'd I do?
08:52
What did I do?
08:54
Oh.
08:59
We're gonna just parse a very old date again
09:01
1900-01-01.
09:06
Somebody's probably screaming at their screen.
09:08
All right, last clean breaker, not last microwave.
09:11
So, so, so, so good.
09:12
No nils.
09:13
Just very recent microwave incidents.
09:17
So that's one way we could do that one.
09:20
All right, so comment.
09:22
Uncomment these ones.
09:24
All right, next up we have these levels
09:25
and if you looked at how this is generated,
09:29
you'd see randomly, your staff manager,
09:32
director, VPC suite.
09:33
These are symbols and they're not gonna
09:36
naturally be sortable.
09:38
So we can do that ourselves.
09:39
We could make a hash of numeric values,
09:41
or we could do like a case statement.
09:44
So user.level, when,
09:49
staff then one when manager,
09:55
then two win director,
09:58
then three when vp,
10:01
then four.
10:02
And finally when you're in the c_suite,
10:04
you're the highest ranking.
10:06
So you're then five and then end of course.
10:10
Okay, so if I run this,
10:11
it won't work 'cause I forgot a comma.
10:15
Comma okay, try that again.
10:19
All right, so that did a thing.
10:21
But let's check that the sort actually worked first.
10:27
By isolating to just that case
10:30
and no it didn't.
10:33
It's showing staff on top.
10:34
And that's because one comes before five.
10:36
So a trick that we can do is we can say negative one times
10:39
and then the negative five will come up first.
10:43
And cool these are all now in the c_suite.
10:45
So that condition works.
10:46
The final condition we had was about distance.
10:50
So I already wrote a little plain ole Ruby object
10:52
called GetsDistance using the geo kit gem.
10:56
So GetsDistance.new.get user.lat, user.long.
11:02
And then the break room has a latitude
11:04
and a longitude as well.
11:07
And that'll give me a value.
11:09
I'm gonna again just focus on isolating one thing at a time.
11:13
If I ran this, it'll blow up.
11:16
Because additionally,
11:18
some users may not have a location there.
11:20
So if there's any nils,
11:21
we're gonna get a nil.
11:22
And then nils aren't comparable.
11:23
And you're now very familiar
11:25
and expert at this sort by pattern.
11:26
All right, so we're gonna just do a quick breakover
11:29
and say minimally distant is what?
11:36
So we want a very high number
11:37
to push you to the bottom of the list.
11:39
And high number would be like float infinity.
11:41
There we go.
11:45
Cool.
11:46
And so if we look at these lat and longs
11:48
you can see that they're kind of close together.
11:49
I don't know where the break room is
11:51
but one presumes, it's near that.
11:53
All right, so those are all our conditions.
11:56
Let's uncomment them all and run it.
11:58
Again, we don't have any tests
12:00
and all this data constantly keeps changing
12:02
but it seems pretty right.
12:04
Okay, so yeah, people who've never cleaned before
12:07
but are active,
12:08
those are gonna float to the top.
12:09
All right, so let's start talking about the Put gem.
12:14
So first we're going to,
12:16
let's see, take a look at our
12:19
readme so it's testdouble/Put.
12:21
Gem install Put.
12:22
You put Put in your gem pile.
12:25
It's three character names.
12:26
So I got excited.
12:27
But like the API is pretty straightforward.
12:29
You have like Put first, Put last, Put ascending
12:32
but it's not like a top level api.
12:33
It's meant to pair with sort by.
12:34
And that's why I think an example is gonna be
12:36
the best way to show everyone.
12:37
All right, so let's add Put to our gem file Put
12:41
and then we're going to bundle.
12:45
Bundle up.
12:46
Great require Put.
12:49
Cool, let's just do one thing at a time.
12:53
So first of all, we know that active users we wanna put
12:56
at the front or the top or first.
12:59
So we're gonna say put first if user.active
13:03
that's all we're gonna say.
13:04
Remove that.
13:05
Now because that's an in line if statement.
13:09
We need to wrap it in parenthesis
13:11
so the parser knows what to do with us.
13:13
We've run that.
13:14
Good, everyone's active.
13:16
So that one's right.
13:18
Now, we actually wanna Put last
13:20
if you have a mobility accommodation.
13:22
So we're gonna say Put last
13:24
if user.accommodations.mobility.
13:30
Okay.
13:32
And we can see real quick.
13:39
Didn't see any mobility.
13:40
So that's sorted in the right order I think.
13:43
Next up the break room thing.
13:45
Now what's nice about Put,
13:47
is it's nil safe by default.
13:49
So we don't have to worry about all these nil cases.
13:51
We can actually just say
13:52
"Put ascending user.last cleaned break room at".
13:57
And that would be all we need to do except
13:58
for the fact that if you've never cleaned the break room
14:00
before we actually want you,
14:01
it'll be nil and we want you to be at the top of the list.
14:04
By default, nils will go to the bottom of the list
14:06
'cause usually they just don't matter.
14:07
But this is the opposite case.
14:08
So we can say nils first true
14:10
with this optional keyword argument.
14:12
All right, so let's whack that.
14:15
Take a look, see if this seems to work.
14:19
Yeah, so you can see these relatively distant cleanings
14:22
followed by nil.
14:23
So all but seven people had cleaned at some point
14:27
and then it's back to like 2021, September.
14:30
Okay, this next case here,
14:32
we had broken down and kind of computed a duration
14:35
to get it into a descending order.
14:36
But we don't have to do that
14:37
because Put actually will have a descending method
14:40
and what it does is it's the same as ascending
14:43
except it'll just like negative-fy the result
14:46
of the comparison operator of A to B.
14:49
And so it'll just know that if it's given a time,
14:54
that the newest time should go on top.
14:58
So we can say last microwaved fish at,
15:02
and here we want the nils to go on the bottom
15:04
'cause people who've never microwave fish
15:06
shouldn't be more responsible for cleaning the break room.
15:09
And so we can just say put descending last microwaved fish
15:12
and that should work.
15:14
And we're gonna check it.
15:16
By just commenting everything out that we got so far.
15:20
Run that.
15:21
And you can see we've got some very recent
15:24
fish microwaving incidents in just a few days ago
15:27
in September, 2022.
15:29
All right, now we got this case statement
15:33
of ranks or levels inside the organization,
15:36
numerified and then multiplied by negative one.
15:39
So here we want to have you go descending by rank.
15:45
So Put descending and then we can actually have
15:49
the same case statement.
15:50
We just get rid of that negative one.
15:55
Now it's a little bit weird
15:56
looking at a case statement like this,
15:57
it probably makes sense to extract it
15:58
or add a method to user,
16:01
but I think that's fine for now.
16:03
And we can just...
16:04
Again, can't hurt to double check,
16:06
run
16:09
this and see.
16:11
yeah, level c_suite.
16:13
All right.
16:14
Okay, commenting out this one.
16:16
So we can take a look at the distance.
16:17
This should be really easy.
16:18
We just get rid of the nil check
16:20
because it handles nils for us.
16:24
All right, so that's an example of...
16:28
Maybe you could have done almost all of this
16:30
in a database order by statement
16:32
but then this last one would've been difficult
16:33
with translating levels to numbers.
16:37
And then this last one might have been very, very difficult
16:39
unless you have like a post GIS or something
16:42
in your database to compare the distance between two points.
16:45
And so we had to do this in Ruby,
16:48
for example, just to get this distance comparison.
16:50
And here we are.
16:51
You can see it still seems to work.
16:53
All right, so uncommenting this
16:57
and just sort of taking it all in,
16:58
and hiding our terminal.
17:00
You can sort of see like it's way clearer.
17:02
It's still not beautiful code
17:04
but if you've seen sort by done before
17:05
you can kind of see,
17:06
"Okay, so top top of the list if they're active,
17:09
last if they've got a mobility impairment,
17:11
ascending order for who's cleaned the break room
17:16
least recently, and if they've never,
17:18
go the to the top and so on and so forth.
17:21
And so that's roughly how you might use Put
17:26
to sort a list of stuff based on complex
17:29
or numerous criteria.
17:33
And so yeah it's a fun little gem.
17:35
That's all it does.
17:36
It just makes sort by blocks like this a little bit clearer
17:40
but I hope that you'll think of it next time
17:41
that you gotta sort stuff in Ruby
17:43
and you got a lot of conditions
17:45
and you don't want to create a whole bunch reams
17:47
and reams of objects to do a lot of complex logic.
17:50
Sort by can do the heavy lifting for you.
17:52
So I hope you find this useful
17:54
and if you have any comments, feedback, or questions,
17:57
feel free to tweet at me, email me or leave a comment.

Justin Searls

Person An icon of a human figure Status
Double Agent
Hash An icon of a hash sign Code Name
Agent 002
Location An icon of a map marker Location
Orlando, FL