In this topic I want to draw attention, show purpose and share initial idea about syntax for them.
Before reading, note: I’m just an enthusiast - don’t take that too seriously and don’t be too critical if something wrong; prepare to bad English.
Jump to second version - text below is not actual
It consists of two parts because in my proposal both are related and second can be combined with first to cover additional use case.
Also, both have different syntax from one that may be found in other languages.
1. Method cascading
Let begin with examples of code in current Rust and highlight some parts that it can improve:
let mut hmap = HashMap::new();
hmap.insert("key1", val1);
hmap.insert("key2", val2);
consume(&hmap);
...
- Some values might remain mutable without a purpose after initialization (e.g.
hmap
might be mutated afterconsume
). - Macro or builder must be provided to reduce initialization boilerplate.
let mut result = OpenOptions::new()
.write(false)
.read(true)
.open("location")?
.to_some_collection();
result.sort();
return result;
- It’s impossible to distinguish between regular method call chain and fluent interface (no any hints that we call methods on the same type, e.g.
open
- where call chain begins) - Method chains and fluent interfaces are incompatible with functions that returns values other than
self
(likeinsert
andsort
). - Nothing says that the same struct is returned from
fn like_this (&self) -> Self
(we only know that struct with the same type is returned). - Mutations can be hidden in method chains (collection methods like
sort
,push
,extend
solves that with returning()
instead ofself
).
Proposal
Allow to prepend ~
when calling method that takes self
by reference.
That will discard its return value and return self instead.
let hmap = HashMap::new() // `hmap` don't declared as `mut` here.
.~insert("key1", val1) // `~` returns `hmap`.
.~insert("key2", val2); // `.~` chain shows that we operate on `hmap`.
consume(&hmap); // `hmap` is immutable further.
...
return OpenOptions::new()
.~write(true) // Setters don't needs to return `self`.
.~read(true) // `~` shows that mutation might occur here.
.open("path/to")? // It's clear where method call chain begins.
.to_some_collection()
.~sort(); // No need to introduce additional binding.
Using ~
on methods that takes self
by value will copy that value or produce compile-time error if Copy
don’t implemented for its type.
Methods with #[must_use]
should apply ?
to check returned value before discarding it
file.~read_to_string(&mut buffer)?; // Produces warning without `?`.
Upsides
- Less boilerplate
- Intention of code is cleaner
-
~
perfectly fits on that place
Downsides
- Additional language item.
- Might be confused with
~
operator removed from language. - Returning self is a common practice - should it be considered as a bad practice?
2. Pipe-forward operator
Again, I begin with examples and things that might be improved
let result = second(first());
third(&result);
return result;
- Taken from #2049 - nested function calls must be written in reversed order.
- Side effects (e.g.
third
function) requires temporary variable (Kotlin solves that withapply
andalso
extension functions).
let mut dir_path_buf = PathBuf::from("base");
fs::create_dir_all(&dir_path_buf)?;
dir_path_buf.push("filename");
return File::open(dir_path_buf);
- Too many things must be placed on the same level of indentation and that distracts from essential parts.
- Code might become bloated with repeating words (e.g.
dir_path_buf
on all 4 lines). - Temporary bindings must be named. But naming is hard. And that’s sometimes just annoying routine.
let content = {
let mut buffer = String::new();
file.read_to_string(&mut buffer)?;
buffer
};
- One binding, scope, level of indentation and two lines of code are required to isolate temporary binding.
- Even if such code is very clean and descriptive it might be shorter (even one-liner in some languages).
Proposal
Like method cascading it’s based on .
extension.
Expression form is value.(function)
that is the same as function(value)
.
And it might be combined with &
, &mut
, ~
and ?
, the following table demonstrates all possibillities:
Expr | Fn arg | Expr result | Expr in current Rust |
---|---|---|---|
val.(by_val) |
moved | fn result | by_val(val) |
val.(&by_ref) |
borrowed | fn result | by_ref(&val) |
val.(&mut by_mut_ref) |
mut borrowed | fn result | by_mut_ref(&mut val) |
val.(~by_val) |
cloned | val | { by_val(val); val } |
val.(~&by_ref) |
borrowed | val | { by_ref(&val); val } |
val.(~&mut by_mut_ref) |
mut borrowed | val | { by_mut_ref(&mut val); val } |
val.(try_by_val?) |
moved | fn result | { try_by_val(val)? } |
val.(&try_by_ref?) |
borrowed | fn result | { try_by_ref(&mut val)? } |
val.(&mut try_by_mut_ref?) |
mut borrowed | fn result | { try_by_mut_ref(&mut val)? } |
val.(~try_by_val?) |
cloned | val | { try_by_val(val)?; val } |
val.(~&try_by_ref?) |
borrowed | val | { try_by_ref(&val)?; val } |
val.(~&mut try_by_mut_ref?) |
mut borrowed | val | { try_by_mut_ref(&mut val)? val } |
Now let’s see rewritten examples
return first() // No need in additional bindings.
.(second) // Functions written in natural order.
.(~third) // Copies result of `second` in `third` and returns it.
return PathBuf::from("base") // No need in additional bindings.
.(~&fs::create_dir_all?) // PathBuf is borrowed to fs::create_dir_all.
.~push("filename") // PathBuf is mutated here.
.(File::open) // PathBuf is moved to `File::open` function
let content = String::new() // No need in additional bindings.
.(~&mut file.read_to_string?); // Mutably borrowed to `file.read_to_string`
I don’t expect that to work with inline closures because file.read_to_string
usage on previous example will conflict. Also error checking with ?
might become a mess.
And IMO it will be ugly, so better to extract closure into variable and then use it:
// BAD example
value
.(&mut move |x| function1(x, moved, 42))
.(~|x| {
function2(x);
function3()
}?);
// GOOD example
let do_func1 = move |x| function1(x, moved, 42);
let do_func2_3 = |x| {
func2(x);
func3()
};
value
.(&mut do_func1)
.(~do_func2_3?)
Upsides
- Code is shorter and faster to read/write
- Hard to abuse it and make code unredable (like with
also/apply
extensions in Kotlin) - API’s will have better ergonomics without programming effort
- Rust will provide alternative to
|>
(pipe-forward operator) that works with borrowing system
Downsides
- Additional language item
- Code is more implicit and “compressed”
- Harder to debug and modify code without temporary bindings
- There will be two ways to achieve the same result
- Syntax is complex enough
Feedback needed:
- Does any of it have parser ambiguities? Any technical issues?
- Can someone provide examples when it will be confusing rather than readable?
- Suggest alternative syntax? Symbol other than ‘
~
’? - Suggest alternative naming (e.g. “apply method” and “apply external method”)?
- Does it introduce any runtime/compiletime performance impact/imporvement?
- Should it be split in two separate features?
- Can somebody help with writing RFC?