Immutability
Bài đăng này đã không được cập nhật trong 8 năm
Immutability
Immutability is one really good concept in programming. It's really really good. Immutable means that you don't change an object state nor re-assign variable once it is created. You create a value and then after that you can only read it. If you want to modify it, you need to create a new instance and assign to a new variable. For example, the following codes are not immutable.
i = i + 1 # re-assignment
array.push 3 # change state
Some of the Ruby's good practice comes from immutability so actually you've already familiar with this concept without realizing so. You might feel that it heavily limits your capability of programming. But you will soon know that it leads you to higher level as a programmer. Constraint is a mother of invention. And history of programming language is a history of constraints.
Examples
Example1: Array
Look at the following code. This code get two list of users from a list of all users, one is the list of normal users and the other is the list of paid users.
users = User.all
normal_users = []
paid_users = []
users.each do |u|
case u.type
when :normal
normal_users << u
when :paid
paid_users << u
when :left
# do nothing
end
end
You might think it's OK. But I don't think so. It's not ruby-ish at all.
There are two mutable variables normal_users
and paid_users
. So we want to stop this way.
The best code I can think of is as follows.
users = User.all
normal_users, paid_users = users.group_by(&:type).values_at(:normal, :paid)
As you can see, the modified code is much
- Compact
- Intuitive
- Easy to understand
- Easy to maintain
You might think it is because Ruby's group_by
and values_at
methods are strong. Partly it is right. But partly not.
These strong methods are created to accomplish immutability. And those benefits come from immutability.
So if you find a mutation inside some loop, you can reconsider to change it into a immutable way. Then you will find your code becomes much cleaner.
Example2: Boolean
generate = false
generate = true if user.paid_user?
generate = false if user.new?
generate = true if user.normal_user?
In this case the generate
variable is mutated and it is modified many times. And it is a bit hard to understand.
So let's refactor it.
Obviously the above code is equivalent to the following code.
generate =
user.paid_user? &&
! user.new? &&
user.normal_user?
Can you explain why these two codes are the same? If you can explain it, that's great. That's really surprising because they are not equivalent.
Actually the original code is equivalent to the following code.
generate =
( user.paid_user? && ! user.new?) ||
user.normal_user?
Please have a time to compare these codes by yourself. It might be a bit surprising that there are ||
and &&
mixed.
So that's the problem of the original code. At first sight it looks easy to understand. But actually some confusing dependencies between lines are hidden behind the code. If you swap the 3rd and 4th lines of the original code, it changes the meaning, but such a constraint is not visible at first glance. You can notice such a dependency after you load the code into your brain and imagine how it will run, which takes much time.
On the other hand, in the last code, the logic and constraints is very clear from it's syntax.
You can easily know from the bracket that you can not swap ! user.new?
and user.normal_user?
. It visually prevent you from making a stupid mistake.
This is basically what a state annoyingly does for us. We can write a program that depends on the state of variables. But the state is not visible in the code. So IDE nor compiler can not help you. And the state depends on the order of execution. So it's fragile by moving some lines of code. You need to spend time to imagine how it runs to understand in which state the variable is for every specific line of code. So it takes time. These are the part of reasons functional people say state is evil (there are more reasons) and love immutability.
To be more clear, these problem happens because the meaning of a variable changes before/after the mutation.
i = i + 1
Think about this code. The value of i
changes at this line. So reading before this line and after this line requires you to refresh your memory.
If there are many more mutation on many variables, it makes the code much harder to understand.
But if the code is immutable, then any variable has only one meaning.
If you look at one variable, then it's meaning is always same through it's scope. That makes things very simple.
It might be interesting to know that this concept was introduced by mathematician.
In a mathematics world, we can never write an equation like i = i + 1
. Mathematicial will deduce from the equation that 0 = 1
, by reducing i
from both side of the equation
Example3: Query Builder
Before the age of rails, when we make an SQL query, we can make an QueryBuilder class like this.
q = QueryBuilder.new( User )
q.add_condition :user_type, :eq, :paid
q.add_condition :created_at, :ge, Date.new(2014,1,1)
q.order_by "id desc"
q.limit 10
q.execute #=> a list of users
In this case q
is mutated.
The problem of this code is that it's not easy to reuse and compose a query. Because every time you add new condition or else, the state of the QueryBuilder instance is changed.
So you can not share one instance between two part. If you do it, changing the state of q might affect the other, which causes difficult bugs.
For the best solution you can remember AREL interface.
User.where(user_type: "paid").where("created_at >= ?", Date.new(2014,1,1)).order("id desc").limit(10)
You know that AREL is highly reusable as scopes. Also it is highly composable - you can combine many scopes as a building blocks for a big query. It's because every time we call where() or order(), it will create a new query instance instead of changing the state of query. Reusability and composability is the power of immutability.
Conclusion
As you see, mutating a variable is normally lead to difficult and not maintainable code. So please encourage yourself not to use mutable variable as much as possible. It does improve your code quality. It's far more powerful concept than naive measurements like length of line, length of method, space between blah blah, indentation, whatever rubocop can easily check (I searched quickly if rubocop can check re-assignments or not but I could not find any resource).
But actually immutability is not always good because, the more complicated your code is, the more difficult to convert it to the immutable way. Sometimes it requires you to build a big amount of infrastructure code. (For example, AREL is really great but it takes a huge amount of time if you do the same thing) So we can focus on the core logic of your application or the most difficult part of your application.
Please try to make your code immutable when
- You have a code that you want to test by RSpec
- You implement a complicated business logic
- You write a library which is used from many places of your application
- You implement an algorithm
All rights reserved