Sometimes one wants to create a variable or constant which refers to some mutable data type, but which is immutable itself. A realistic but slimmed down example might be something like
# In the project main file, e.g. project.cr
class Project
class_property config : Config = Config.new
end
# In `config.cr`
class Config
record User, name : String
property users = Array(User)
end
# in spec/fixtures.cr
module Fixtures
CONFIG = Config.new users: [User.new "test user"]
def reset_config
Project.config = CONFIG
end
end
# in spec/spec_helper.cr
Spec.before_each { Fixtures.reset_config }
# in spec/add_user.cr
describe "Config" do
describe "adding a user" do
it "works" do
Project.config.users << Config::User.new "added user"
Project.config.users.map(&.name).should contain "added user"
end
end
end
describe "An immutable constant" do
it "has not been changed" do
Project.config.users.map(&.name).should_not contain "added user"
end
end
This would fail under the current system, because, while you can’t do Fixtures::CONFIG = # something, you can do Fixtures::CONFIG.method_that_mutates!. It is a common request in the chat to be able to create some constant for which not only the reference, but the value to which the reference is pointing is immutable.
A good start would be a macro which uses an annotation like @[Immutable] or @[Read_Only], which overloads setter methods to raise a compile-time error.
This should, in my opinion, be a standard library feature. However, barring that, I would like to begin the process of creating a shard which implements this feature via macros, if the currently implemented macros are up to the task.
I think this is impossible to implement with macros. You need it to be a language feature. But constants like that are really hard to implement or they make the type system really complex. See D language for example.
Now, don’t get too excited about this: it’s just an experiment, it results in a lot of duplicated code, and almost anything else other than what is shown there isn’t working. Also, I’m not sure how useful this is, compared to the complexity it brings (both in terms of the language and the implementation).
There are already immutable types in the language, however all of them have some usablility problems.
There are of course Structs, Tuples and NamedTuples. There are also Slices, however the read-only check there is a runtime one.
I would propose avoiding const, we’ve all seen the damage already. Instead the way forward in my opinion is a) extending type system, maybe something in a way of Scala/Rust/Haskell and b) avoiding sharing pointers to the data.
Your example deals with a global constant exposing its users directly. What you could do instead is hide the data behind a custom interface that only gives away the immutable methods. Now, unfortunately Crystal doesn’t have private instance vars, so if you need extra protection what you could do is start a fiber sitting atop of a channel and allocate the data inside that fiber so nobody else has the pointer.
That was my original idea – the one I said I thought could be implemented through macros. A macro or annotation could create a duplicate of the original type with all of the methods ending in = overwritten to raise a compile-time exception. But that wouldn’t work of course – a method might mutate an ivar without ever calling a setter method.
Is there any particular reason a frozen flag wouldn’t work for mutable data types such as Array and Hash? Like in Ruby we could have the #freeze, #unfreeze, and #frozen? methods. All it would require is setting a frozen instance variable, and then checking that frozen is false before allowing modifications to the data.
The way freeze works in Ruby is that you can freeze any object. Then you get an error when you try to reassign any instance variable in that object.
For Crystal it means every instance variable access will have this extra check. Instance variable reads can’t be inlined anymore. Everything becomes slower, I guess. Plus an object’s size grows a bit.
Maybe someone could try it out, I don’t know. but
Well, that was kinda what I was thinking – you could create an immutable macro which just does like this (I realize this won’t run but it presents the idea)…
macro immutable(reference) # reference being a TypeDeclaration
class ImmutableVersionOf{{reference.type}} < {{reference.type}}
{% for method in @type.methods %
{% if method.name.ends_with? '=' %}
def {{method.name}}
{% raise "Cannot call setter on immutable {{reference.type}}" %}
end
{% end %}
end
end
But then, after seeing the above discussion above, I realized – not all mutations on a variable happen through setters, and not all mutations even do @ivar = something, so just disallowing setters wouldn’t be far enough. I agree with @asterite that this probably would need to be a core language feature. It’s certainly one I would advocate for but I understand that it may not be a huge priority and I have no idea how difficult it would be to implement something like that.
I think the best solution would be a generic type that removes a set of methods from a type. Then you get compile time warnings like i mentioned in Immutable references to mutable reference types
While that that may be a convenient way to generate a simple read-only version of a type, it would be very fragile, and not really immutable. (For example…) What we would need to do is detect these sorts of mutations (+=, -=, ~=, []=, etc.) at the IR/compiler level… which I have no idea how difficult that would be.