Best practices for visibility of fields

While working with Rust over the last few months, I've been trying to understand the best practices around visibility of fields in structs. My inclination is to default to making fields private and use getters/setters to interact with them; however, that leads to two issues (that I've run into):

  1. You can't deconstruct a type using pattern matching; which is very nice syntactic sugar to have available.
  2. When using getters/setters, the borrow rules checker becomes lower resolution and will mark the entire struct as being borrowed even if you are only borrowing a single field. For example, in let x = s.get_z() and let x = &s.z the former would consider s to be borrowed for the rest of the life of x while the latter would only consider s.z to be borrowed for the life of x.

Those two items create some pressure for me to make fields public for some level (usually pub(super)); but I'm not sure if this is "smell" that points to issues in my designs that I am missing or if this is considered perfectly fine in Rust (and my concerns are just carry over from OOP). So are there best practices or guidelines around visibility in Rust?

5 Likes

Any guidelines like this should be shaped according to the stability guarantees you want to accept. Changing public fields is a breaking change, and all of the benefits you'd see are specific to how people interact with the struct, which means you need to define an API. Those issues that you've pointed out are a natural consequence of constraining an API so that your users won't misuse it. In doing so, you have to think about your structs as opaque data tokens and define your interactions in terms of which functions and methods are available (at a "high level" of abstraction), not which fields have which values (a "lower-level" concern.)

So it does depend on what you're doing and who you're doing it for.

3 Likes

Getters/setters are problematic for Rust, so they should be used only if you have to, not just in case. Complete opposite of Java best practices.

It's best to split structs into two different kinds: primitive ones, that are so simple they can afford have public fields (e.g. Point) and clever ones that may need to have a private state.

9 Likes

I always make struct fields private and use getters/setters when building prototypes. It makes it far easier for me to change my mind, since most changes are purely local. It also makes it easier for me to log access to those fields, which can help with debugging. That decision is sometimes, but not often, changed in the production code.

So far, I've been able to work around the resulting borrow checker problems (Please hurry up and get here, Polonius.) in a way that hasn't hurt clarity or performance (at least enough for me to notice).

2 Likes

I found the guidelines about (avoiding) getters and setters from rust analyzer style guide quite insightful: https://github.com/rust-analyzer/rust-analyzer/blob/master/docs/dev/style.md#getters--setters

5 Likes

I would also distinguish between structs that have unsafe aspects, which usually require module level boundaries and typically want private innards, and crate-private data structures, which can have public fields because of they change then it only affects your crate, which is usually no problem to adjust.

1 Like

For "plain old data" types where consumers just want to access the object's fields I'll leave everything public, possibly adding a #[non_exhaustive] attribute if I want to add fields later on.

For objects with business logic I might expose getter functions to let the caller inspect something (e.g. fn people(&self) -> &[Person]), but I will rarely give them a mutable reference (&mut [Person]). Outside of very specialised circumstances where you need tight control over what value a field is set to (mainly safety and logic invariants) will never expose setters.

I believe the thinking of "I'll put this field behind a getter and setter so I can change it later" is flawed... In the vast majority of cases (I can probably count the number of exceptions I've seen on one hand), you'll:

  1. never change the field
  2. Never do any processing inside the setter, or
  3. Restructure your type so much that the original getter/setter signatures are now incompatible with how you want to do things (e.g. the getter previously returned a reference, but now you've deleted the field and constructing a new owned instance on each access).

So you haven't gained anything in backwards compatibility, encapsulation or readability.

3 Likes

How are they problematic (outside the various API issues discussed here) and how problematic are they for just internal use within a crate? (and/or is this an instance where inlining could help?)

I'm currently looking at using intermediary structs with &dyn SomeGetters trait fields. I'm trying to avoid making overly customized data structs for needing to do a hundred-ish of reads of about a dozen (or more) f64 fields from any of a variety of sizes of struct. Plus a couple of the variables to get have a complex enough calculation that having the option to build some memoization in the final getters might be beneficial.

The option I'm trying to beat is using a HashMap: Something that can read all values just once to build, can request only the needed values at construction, and it is fully memoized! But it's heap allocated...

They are problematic for the borrow checker. The getters/setters borrow all of self, all fields at once. There's no way to explain to the borrow checker that the getter/setter operates on only a single field. So any use of any field locks the whole object. This very often becomes limiting, because you can't do, e.g. obj.set_field2(obj.get_field1()), but you could obj.field2 = obj.field1.

9 Likes

Makes sense, thanks! That's a problem I think I can avoid. I'm working towards having the "moving parts" of my code based mostly on immutable state and using variable shadowing to handle state changes via into functions. I suppose the issue on that is ensuring that all the various references are dropped before calling the consuming into functions. And now I've gone completely off-topic...

But also, really strong argument on preferring direct access to fields. A simple &var.field27 is far better than taking the whole &self just to get field27!

Edit: Just realized this was point #2 in the original post... Are there any other issues?

If your type has private state, you should probably not allow others to destructure it. If it's a wrapper type, provide an into_inner() method for it.

4 Likes

On a semi-related note: are there any good, thorough write ups on the rules and analysis that the borrow checker performs? I found that the beginners book provides a good basis to get started but once I started writing more complex programs I feel like I'm having to reverse engineer the borrow rules from resolving compiler errors. In fact, for over a month, I thought that that it was always the case that the entire struct was marked as borrowed if you borrowed a field, whether it was through a getter method or directly. And I am getting to the point where I think fully understanding the borrow rules is very important to designing good Rust code.

1 Like

Re "other issues", I'm not sure if you're referring to other issues that might be present from your previous post or other issues regarding my post.

If the former, what I can say as I've been reading these responses and thinking about my own code, is that I've been moving more towards viewing the boundaries (between where I would make fields accessible and where I would make them inaccessible except through functions) as being sub-modules within my crate. Within a sub-module I have the key data structure(s) and all the functions that need to operate on that data. Then for interaction with the submodule, I expose functions or methods for providing the inputs and getting the outputs. So far, this is giving me a good balance between having full and easy access to data where I need it and preventing the complexities of data structure definitions from becoming enmeshed throughout my program.

edit: one additional thing occurs to me, based on my experience coming to Rust from several years of functional programming. Ask yourself why you want to use immutability in your program.

In my opinion, the purpose of immutability in FP is to make it easier to manage and reason about state. If you have a set of data X and I want to transform it into Y, immutability prevents me from just changing the data you have, the immutability gives strict guidelines on how state can change to make it as easy as possible to reason about your code. But this is exactly the problem that Rust's borrow/move rules solve: I either have to explicitly borrow the data from you as mutable (and no one else can borrow the data as mutable or immutable) or you have to explicitly move the data to me. So, if you're using immutability to manage state then, IME, you're losing a lot of the best benefits of Rust and will find yourself working against the language.

If it's my post,, I'm sure there are some other issues I missed to think about. One thing that occurred to me today is that I have not given any thought to how this question relates to enums or tuples.

1 Like

Not sure what kind of list you are looking for, because the basic rules are extremely simple:

  • only one mut borrow at a time;
  • no simultaneous mutable and immutable borrows;
  • no moving while borrowed (mutably or immutably).

Specific compiler errors can, of course, be explained only in their own context, that is, alongside the concrete code that generated them. This is becase there are a multitude of ways in which you can violate the above three rules, so it's not a reasonable expectation to compile an exhaustive list of ways you can generate borrowck errors.

It is exactly the case that when the borrow checker flags your code as invalid, you thought you were doing it right but you made a mistake; therefore, the only realistic way to proceed is to think through your own code (and possibly ask for help if you are stuck) in order to find out why it violated one or more of the 3 basic rules. Experience helps, but every once in a while you'll inevitably run into something that you haven't seen before, and that's OK.

It seems that pub fields vs. getters/setters comes entirely down to style or API choices, except for the one huge potential issue with borrowing &self. That's actually quite a nice result, as far as I'm concerned. I kept expecting some dark and obscure issue with a huge RFC that I hadn't heard about yet. For the most part I'm perfectly well served using pub(in crate) structs with pub fields to give free access internal to the crate. Then I just have to select the public access points and make sure they work as needed.

On the immutability question, it comes entirely down to being the best option for concurrency; all the immutable references I want! That alone is a huge win to entirely avoid Arc/RwLock/Mutex/etc. That's actually where I think the true benefit of Rust is - there are many ways to achieve a particular goal, but if you can do it in a way that avoids fighting the borrow checker then it's probably a good way to do it!

2 Likes

To my mind, using getters/setters is like defining an API for your microservice; public access to the fields is like giving services direct access to each other's databases.

That seems like a poor comparison as trust models are completely different. Let's of course remember that the "consumer" of a struct already has "access to the database": if one needs access to a field that is needlessly private, it's all just memory. It might be unsavoury (transmute, etc), but you can get there.

If accessing a struct field directly has a high chance of introducing memory unsafety, soundness issues, security issues (e.g. in crypto code), or other unsafe or unsupported behaviour, then that's when fields should be private. Otherwise, that's just putting barriers where none are needed. Additionally, nothing prevents you from having a setter-with-side effects (logging, etc) on a public field. Document that the field should be written to with the method if required.

They define a contract. One aspect of which is "don't be surprised when everything breaks if you unsafely work around the privacy."

Even more formally: it's a semver breaking change to alter or remove a field you've made public. And many other privacy based changes as well; more details can be found in this RFC.

Making a field public (or not making any field private or not marking your struct as non-exhaustive, etc.) is a semver commitment. One of the uses of privacy is to preserve the ability to evolve your code without introducing breaking changes.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.