[Pre-RFC] read-only visibility

Summary

Add read-only visibility to fields, e.g. pub(&), pub(&crate), pub(crate, &). A read-only field can not be assigned to or mutably borrowed.

Motivation

Visibility modifiers of structs provide encapsulation, to hide implementation details and prevent modifications that violate the struct's invariants.

Often it's okay to make fields visible, as long as they aren't modified. To do this, we have to make them private and add getters. However, this has several disadvantages:

  • private fields can't be destructured or pattern-matched
  • you might need two getters per field: one borrowing the struct and one moving it
  • getters can't take ownership of just a part of the struct
  • converting a public field into a private field with getter is a lot of work, if the field is used a lot

Guide-level explanation

A visibility modifier of a field can contain a “&” to indicate that the field can be immutably borrowed, but not mutably. In other words, you can get a shared reference to the field, but not an exclusive reference.

Example:

pub struct Foo {
    pub(&) inner: Inner,
}

pub struct Inner();

This is allowed anywhere:

// assume that foo has the type Foo.

let Foo { inner: _ } = foo;            // destructuring
if let Foo { inner: Inner() } = foo {} // pattern matching
let inner = foo.inner;                 // move

This is forbidden in other modules:

let mut foo = Foo {
    inner: Inner(),         // error: Foo::inner is read-only
};

let inner = &mut foo.inner; // error: foo.inner can't be mutably borrowed,
                            // because it's read-only

foo.inner = Inner();        // error: foo.inner can't be assigned,
                            // because it's read-only

Different visibility modifiers

  • pub(&) - read-only visibility everywhere
  • pub(&in simple_path) - read-only visibility in simple_path.
  • pub(&crate) - read-only visibility in the same crate
  • pub(&super) - read-only visibility in the parent module
  • pub(&self) - read-only visibility in the same module (this has no effect)

Additionally, read-only visibility can be combined with normal visibility, for example:

  • pub(crate, &) - full visibility in the same crate, read-only visibility everywhere
  • pub(super, &crate) - full visibility in the parent module, read-only visibility in the same crate

The full visibility must appear before the read-only visibility.

Mutability? Sharability? Writability?

In Rust there's some disagreement what these terms mean. In Rust, shared references are called “immutable” although that's not always the case. However, I'd like to ignore this aspect for a moment.

I use the term “read-only” instead of other choices like “immutable”, “sharable” or “non-exclusive”, because things like immutability or sharability are inherent properties of reference types. A read-only field, on the other hand, can only be written to in certain modules. It's not about the type system, it's about the module system.

EDIT: See below in Unresolved Questions for more information.

Reference-level explanation

struct and union fields get a new kind of visibility modifier, called DataVisibility.

Grammar

DataVisibility ::= 'pub'
                 | 'pub' '(' VisibilityScope ')'
                 | 'pub' '(' '&' ')'
                 | 'pub' '(' '&' VisibilityScope ')'
                 | 'pub' '(' VisibilityScope ',' '&' ')'
                 | 'pub' '(' VisibilityScope ',' '&' VisibilityScope ')'

VisibilityScope ::= 'crate' | 'self' | 'super' | 'in' SimplePath

Detailed design

There are now two levels of visibility:

  • full visibility (same as before)
  • read-only visibility (a subset of full visibility)

A field always has full visibility in the module in which it was declared and its sub-modules. It can be extended by specifying a full visibility, read-only visibility, or both. The full visibility takes precedence: pub(crate, &crate) is equivalent to pub(crate).

The read-only visibility should be larger than the full visibility. For instance, pub(crate, &crate) or pub(&self) doesn't make sense and should issue a warning.

In places where a field has only read-only visibility, the following things are allowed:

  • destructuring the field
  • pattern matching on the field
  • borrowing the field immutably
  • taking ownership of the field/moving it

The following things are not allowed:

  • passing a value for the field to the struct's initializer (see unresolved questions below)
  • assigning it a new value
  • borrowing the field mutably

Drawbacks

It makes the module system more complicated.

Rationale and alternatives

  • Properties (getters and setters that can be used like a field) have been proposed at least twice. They are common in languages like Kotlin or Typescript. In Rust, implementing properties would be difficult, and their benefits would be very limited.

  • Also have a notion of write-only. This wouldn't be very useful: It wouldn't allow you to borrow it, and there aren't many use cases anyway

  • Allow writing the read-only visibility before the full visibility: This might lead to confusion, because pub(&,crate) looks very similar to pub(&crate).

  • Make nonsensical visibility a hard error, e.g. pub(crate, &crate) or pub(&self). I think supporting this syntax might be desirable in edge cases (e.g. macros), so a warning should be enough.

  • Different syntax: Ideas in this thread include &pub(crate), pub(crate as &), pub(&, &mut crate), pub(ref crate), pub(get, set crate)

Of all the possible designs I considered, I believe that my proposal is the simplest, most consistent, and easiest to understand.

Prior art

Property with a private setter in Kotlin:

var setterVisibility: String = "abc"
    private set // the setter is private and has the default implementation

And C#:

public int MyProperty { get; private set; }

Typescript has a readonly keyword for fields, but with a different purpose, so you need to implement a getter:

private _prop: string;
public get prop() : string {
    return this._prop;
}

Unresolved questions

Name

I'm not entirely satisfied with the term “read-only”, because it might lead people to believe that fields with read-only visibility are immutable. However, a field with read-only visibility can still be mutated in the same module, and it can be mutated anywhere, if it has interior mutability.

Other ideas:

  • sharable
  • borrowable
  • non-exclusive
  • non-unique referencable
  • non-writable
  • non-settable
  • share-only
  • fetch-only
  • accessible only non-exclusively

I'd like to hear your opinion, and maybe other suggestions! To decide this democratically, I'll create a poll in a few days.

Initializer

We could allow initializing a struct, even if it contains read-only fields: To prevent this, the struct can be made #[non_exhaustive], which means that the struct can only be initialized in the same module. This feature is not stable yet, but it's likely that it will be stabilized soon-ish.

  • PRO: The rules for read-only visibility become easier: Read-only visibility only prevents borrowing the field mutably.

    Note that an assignment like foo.bar = baz; can be written as (&mut foo).bar = baz.

  • CON: A macro must be added to all structs with read-only visible fields. If you forget this, the struct's invariants can be violated.

  • CON: We can't write #[non_exhaustive(super | crate | in path)].

9 Likes

This is my first RFC, please tell me if there are mistakes or something important is missing, I appreciate any help!

As I said in the linked thread, I am extremely against using get/set terminology because it is actively misleading.

Also note, there is no way to specify read-only in Rust due to the existence of UnsafeCell and all things built on top of it (modulo the compiler internal Freeze auto trait). I don't think that read-only fields is the correct way to sell this feature. It is better to lean into the notion that &T is a shared reference to T, so these fields would be shareable fields.

I think a deny by default warning is good for this.

Similarly to how Rust doesn't have a notion of read only, it doesn't have a notion of write only, so it would be impossible to enforce this other than just allowing assignment. Not allowing references to these fields is not significantly better than just having a setter for these fields.


link to previous thread where I spoke about this,

3 Likes

As I explained in the RFC: A read-only field is not immutable, it allows interior mutability (just like “immutable borrows”).

This RFC is not about forbidding mutability. I knew that some people would misunderstand it, that's why I wrote a section about it.

If you know a better name for this feature, I'd like to hear it!

If it is not immutable then why not call it "non-unique referencable"

1 Like

I use the term “read-only” instead of other choices like “immutable”, “sharable” or “non-exclusive”, because things like immutability or sharability are inherent properties of a type . A read-only field, on the other hand, can only be written to in certain modules . It's not about the type system, it's about the module system, this is an important distinction.

Yes, I saw that section, I just wanted to reiterate my thoughts because of just naming this read only fields will cause confusion. I guarantee it. There will be a number of people who make a fields with interior mutability "read only", and then see that it does in fact change values. I will see these people on URLO, in the same way that people are confused about what &T actually means. I would like to pre-emptively stop this.

Rust tries to push as much into the type-system as it feasibly can, which is why we have the distinction between &T and &mut T be a type distinction. So by saying

it is actively discrediting the type-system. The only way to access these fields are through references, and since we are talking about access modifiers (visibility), I think we need to lean into the type system is inherently involved.

Other languages don't have a distinction about how data can be accessed with the same granularity that Rust does. They only have the broad strokes of the privacy system. With Rust, we have two difference ways a field could be accessed in addition to the privacy system, by shared reference (&T) or by exclusive reference (&mut T), so we should use that nomenclature to specify our fields visibility.

shareable

I disagree, I don't see sharability as a property of the type. That is why we have shared references, references that can be freely shared that refer to any type.

1 Like

&-visibility for lack of a better term? :slight_smile:

That only works in text, we want something that we can also talk about :slight_smile:

It seems mildly surprising that pub(&) would allow moving. I’m not sure what a good alternative syntax might be, though.

Generally speaking, I sympathize with @RustyYato‘s concern about interior mutability being confusing, but we’ve made that bed and now we have to lie in it. The mut in &mut is short for “mutable”, and that’s not going to change – so we can’t really stop calling them “mutable references”, and so we’re stuck having to teach users that “you can sometimes mutate things via non-mutable references”.

For this reason, I’d be okay with syntax alternatives such as pub(get) or pub(ro), even though they reinforce the misleading terminology.

Edit: FWIW, “read-only” and “immutable” seem synonymous to me.

1 Like

We can, I do and it does clear up confusion. Just because some short sighted decisions were made in the past, doesn't mean we have to keep them.

I call them exclusive references, and then parenthetically say (&mut T) in case someone hasn't come across the terminology before.

Instead say, you can sometimes mutate things through shared references. Then you can link to the amazing read by @mbrubeck: Rust: A unique perspective

2 Likes

Yes, but visibility/privacy is still a separate concept. As a rule of thumb: The type system provides safety. The module system provides encapsulation.

That is not true, and part of the reason why I went for the name “read-only”. A field with read-only visibility can be moved, and it can't be used in the initializer. This is unrelated to shared borrows, so terms like “sharable” or “not uniquely borrowable” will be confusing.

In my first draft I called it “share-only”, but changed it because of this.

What I meant is that immutability or sharability are inherent properties of reference types such as &[String]. These properties don't change when passing a reference to a different module, but the visibility can change.

This is your explanation in the other thread:

I think this is not true. Having a getter doesn't mean that the returned value is immutable. It's not true in Rust, and it's definitely not true in other languages.

I am thinking about renaming the feature to non-settable fields.

That seems like a bit of an anti-feature. Surely we can have a read-only visibility rule that demands the thing its applied to can be implicitly copied/cloned instead of moved, and surely we can provide a mechanism to forbid using this feature for items which would expose interior mutability.

Side note: pub syntax is available on functions too, what would pub(&) fn foo () { } do?

I don't think that this kind of restriction would be useful. Fields with read-only visibility should work exactly the same as normal fields.

Note that getters (e.g. pub fn get_foo(&self) -> &Foo) can expose interior mutability as well, so this is not new at all.

Read-only visibility only applies to struct and union fields, not to functions/types/traits/etc.

1 Like

But you're much less likely to have some field moved by a consumer without explicitly making that happen in the getter.

The idea that somebody could move out some field of my struct worries me, but I might be misunderstanding things.

I would only be wanting people to copy or borrow, not like, take ownership.

1 Like

A field can only be moved out of a struct, if the struct is owned, not borrowed, so this is no problem. If the struct is borrowed, you still have to borrow, copy or clone the field.

Yes, but, how would invariants be properly upheld within the defining module if the fields could be "moved" out of the struct? Are you saying that all "unsafe" code in the module defining the struct could not rely upon the field value being invariantly populated with a legitimate value for the field type? That sounds completely unsound to me. Can someone with more expertise than myself weigh in on this?

EDIT: Thinking and reflecting on this more, I realized that as soon as you move any field out of a struct, then that structs is no longer allowed to have methods called on it that take it as an argument, and so, this is, after-all sound. No?

2 Likes

Yes, I believe so. A soon as at least one field is moved out of a struct, the struct can't be used anymore. It's the same with destructuring.

3 Likes

I agree (as my edited comment above reflects after I though about it more).