Immutable references to mutable reference types

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.
class Project
  class_property config : Config =
# In ``
class Config
  record User, name : String
  property users = Array(User)
# in spec/
module Fixtures
  CONFIG = users: [ "test user"]
  def reset_config
    Project.config = CONFIG
# in spec/
Spec.before_each { Fixtures.reset_config }
# in spec/
describe "Config" do
  describe "adding a user" do
    it "works" do
      Project.config.users << "added user" contain "added user"
describe "An immutable constant" do
  it "has not been changed" do contain "added user"

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.

1 Like

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.

1 Like

I’ve been playing a bit with the idea of const

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.

(This is a separate can of worms though.)

This is what TypeScript’s ReadonlyArray does. It basically is an interface that doesn’t have any the mutable methods on it.

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!

Would it be any easier/harder to have a generic that removes methods from a type? Then if you did like:

ARR : ReadOnlyArray(Array(Int32)) = [1, 2, 3]
ARR << 4 # => Undefined method '<<' for ReadOnlyArray(Array(Int32)) 

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. :slightly_frowning_face:

The problem is much harder than just removing all setters though. Basically it has a lot to do with indirection.

Here’s an entertaining read:

oh wow

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

What about a wrapper type of the original type that would compile-time raise when a setter of the original type is used?

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 '=' %}
      def {{}}
        {% raise "Cannot call setter on immutable {{reference.type}}" %}
    {% 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.

EDIT: Nevermind

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.

One (non-trivial) implementation is to annotate methods which don’t mutate across the stdlib, like

class Array(T)
  def +(other : Array(U)) : Array(T | U)

which would easily allow for creating a macro which raised at compile-time for methods not marked appropriately.

Another alternative is using a completely different type. In fact there is already a shard for that.