For almost a year I've been silently preparing this proposal and such amount of effort was invested because I really think that it has some potential and the feature has so many implications. However, it's possible to say that it's even older as in the past I've also posted a similar proposal that certainly influenced the current, but only this time I've figured out how to properly resolve all issues with symbolic noise, complexity, indistinguishability, insufficient motivation, and general inconsistency with paradigms prevailing in the current Rust, with having solution that might be appealing for everyone.
Basically, I propose to add method cascading like in Dart, but:
- Non-optional: it should become a new standard way to call methods with
(&mut self, ..) -> _
and(Self, ..) -> Self
signatures alike usually applied to update mutable bindings - Operatorless: its syntax derived by subtracting from method call the
.
operator and empty()
parentheses, with some formatting tricks then added to make the result practical - Controllable: it allows to ergonomically select between mutate-in-place and move-and-mutate behaviors while completely eliminating burdening choices like between consuming/borrowing builder flavors
And exactly these properties makes it very different from all prior art.
Unfortunately also implementing it expected to be disruptive enough change to bring a new edition and a lot of potentially chunky additions to tooling plus some paradigm shift in programming in Rust.
Although mechanically the construct is still very simple (if not primitive) and fully backward-compatible (through, may generate a lot of compiler warnings) and nonetheless it possesses a truly surprising amount of consistency with rest of the language — I would even dare to claim that it fits into Rust better than ..
fits into Dart and thus it's impossible to invent any better method cascading syntax!
Before demonstrating it I must warn that it could be very easily misunderstood just because of extreme amount of novelty and it's easy to miss the point that I propose something much more than just a way to save a few symbols. Moreover, with lack of a proper syntax highlighting, with lack of people's experience in editing anything like that, with somewhat unusual alignment of expressions, and with inability to explain all corner cases at once, it's quite expected that there would be a lot of bias against it. Anyway, this proposal exists because I'm convinced that without any bias the proposed syntax should resolve much more problems than it may create and that in many aspects with it Rust seems to be much better than without it.
As you've been introduced, here it is:
// The example was built on code from
// github.com/tensorflow/rust/blob/69e56ed02722a5930f28/examples/xor.rs#L73
fn train<P: AsRef<Path>>(save_dir: P) -> Result<(), Box<dyn Error>> {
// ================
// Build the model.
// ================
let mut scope = Scope::new_root_scope();
let scope = &mut scope;
// Size of the hidden layer.
// This is far more than is necessary, but makes it train more reliably.
let hidden_size: u64 = 8;
let input = ops::Placeholder::new()
dtype (DataType::Float)
shape ([1u64, 2])
.build(&mut scope.with_op_name("input"))?;
let label = ops::Placeholder::new()
dtype (DataType::Float)
shape ([1u64])
.build(&mut scope.with_op_name("label"))?;
// Hidden layer.
let (vars1, layer1) = layer(
input.clone(),
2,
hidden_size,
&|x, scope| Ok(ops::tanh(x, scope)?.into()),
scope,
)?;
// Output layer.
let (vars2, layer2) = layer(
layer1.clone(),
hidden_size,
1,
&|x, _| Ok(x),
scope
)?;
let error = ops::sub(layer2.clone(), label.clone(), scope)?;
let error_squared = ops::mul(error.clone(), error, scope)?;
let optimizer = AdadeltaOptimizer::new()
set_learning_rate (ops::constant(1.0f32, scope)?);
let variables = Vec::new()
extend (vars1)
extend (vars2);
let (minimizer_vars, minimize) = optimizer.minimize(
scope,
error_squared.clone().into(),
MinimizeOptions::default().with_variables(&variables),
)?;
let all_vars = variables.clone()
extend_from_slice (&minimizer_vars);
let saved_model_saver = tensorflow::SavedModelBuilder::new()
collection ("train", &all_vars)
tag ("serve")
tag ("train")
signature (
REGRESS_METHOD_NAME,
SignatureDef::new(REGRESS_METHOD_NAME.to_string())
input_info (
REGRESS_INPUTS.to_string(),
TensorInfo::new(
DataType::Float,
Shape::from(None),
OutputName {
name: input.name()?,
index: 0,
},
) )
output_info (
REGRESS_OUTPUTS.to_string(),
TensorInfo::new(
DataType::Float,
Shape::from(None),
layer2.name()?
) )
, )
.inject(scope)?;
// =========================
// Initialize the variables.
// =========================
let options = SessionOptions::new();
let g = scope.graph_mut();
let session = Session::new(&options, &g)?;
let mut run_args = SessionRunArgs::new();
// Initialize variables we defined.
for var in &variables {
run_args target (&var.initializer());
}
// Initialize variables the optimizer defined.
for var in &minimizer_vars {
run_args target (&var.initializer());
}
session.run(&mut run_args)?;
// ================
// Train the model.
// ================
let mut input_tensor = Tensor::<f32>::new(&[1, 2]);
let mut label_tensor = Tensor::<f32>::new(&[1]);
// Helper that generates a training example from an integer, trains on that
// example, and returns the error.
let mut train = |i| -> Result<f32, Box<dyn Error>> {
input_tensor[0] = (i & 1) as f32;
input_tensor[1] = ((i >> 1) & 1) as f32;
label_tensor[0] = ((i & 1) ^ ((i >> 1) & 1)) as f32;
let mut run_args = SessionRunArgs::new()
target (&minimize);
let error_squared_fetch = run_args.request_fetch(&error_squared, 0);
run_args
feed (&input, 0, &input_tensor)
feed (&label, 0, &label_tensor);
session.run(&mut run_args)?;
Ok(run_args.fetch::<f32>(error_squared_fetch)?[0])
};
for i in 0..10000 {
train(i)?;
}
// ================
// Save the model.
// ================
saved_model_saver.save(&session, &g, &save_dir)?;
// ===================
// Evaluate the model.
// ===================
for i in 0..4 {
let error = train(i)?;
println!("Error: {}", error);
if error > 0.1 {
return Err(Box::new(Status::new_set(
Code::Internal,
&format!("Error too high: {}", error),
)?));
}
}
Ok(())
}
Original piece of code
fn train<P: AsRef<Path>>(save_dir: P) -> Result<(), Box<dyn Error>> {
// ================
// Build the model.
// ================
let mut scope = Scope::new_root_scope();
let scope = &mut scope;
// Size of the hidden layer.
// This is far more than is necessary, but makes it train more reliably.
let hidden_size: u64 = 8;
let input = ops::Placeholder::new()
.dtype(DataType::Float)
.shape([1u64, 2])
.build(&mut scope.with_op_name("input"))?;
let label = ops::Placeholder::new()
.dtype(DataType::Float)
.shape([1u64])
.build(&mut scope.with_op_name("label"))?;
// Hidden layer.
let (vars1, layer1) = layer(
input.clone(),
2,
hidden_size,
&|x, scope| Ok(ops::tanh(x, scope)?.into()),
scope,
)?;
// Output layer.
let (vars2, layer2) = layer(
layer1.clone(),
hidden_size,
1,
&|x, _| Ok(x),
scope
)?;
let error = ops::sub(layer2.clone(), label.clone(), scope)?;
let error_squared = ops::mul(error.clone(), error, scope)?;
let mut optimizer = AdadeltaOptimizer::new();
optimizer.set_learning_rate(ops::constant(1.0f32, scope)?);
let mut variables = Vec::new();
variables.extend(vars1);
variables.extend(vars2);
let (minimizer_vars, minimize) = optimizer.minimize(
scope,
error_squared.clone().into(),
MinimizeOptions::default().with_variables(&variables),
)?;
let mut all_vars = variables.clone();
all_vars.extend_from_slice(&minimizer_vars);
let mut builder = tensorflow::SavedModelBuilder::new();
builder
.add_collection("train", &all_vars)
.add_tag("serve")
.add_tag("train")
.add_signature(REGRESS_METHOD_NAME, {
let mut def = SignatureDef::new(REGRESS_METHOD_NAME.to_string());
def.add_input_info(
REGRESS_INPUTS.to_string(),
TensorInfo::new(
DataType::Float,
Shape::from(None),
OutputName {
name: input.name()?,
index: 0,
},
),
);
def.add_output_info(
REGRESS_OUTPUTS.to_string(),
TensorInfo::new(
DataType::Float,
Shape::from(None),
layer2.name()?
),
);
def
});
let saved_model_saver = builder.inject(scope)?;
// =========================
// Initialize the variables.
// =========================
let options = SessionOptions::new();
let g = scope.graph_mut();
let session = Session::new(&options, &g)?;
let mut run_args = SessionRunArgs::new();
// Initialize variables we defined.
for var in &variables {
run_args.add_target(&var.initializer());
}
// Initialize variables the optimizer defined.
for var in &minimizer_vars {
run_args.add_target(&var.initializer());
}
session.run(&mut run_args)?;
// ================
// Train the model.
// ================
let mut input_tensor = Tensor::<f32>::new(&[1, 2]);
let mut label_tensor = Tensor::<f32>::new(&[1]);
// Helper that generates a training example from an integer, trains on that
// example, and returns the error.
let mut train = |i| -> Result<f32, Box<dyn Error>> {
input_tensor[0] = (i & 1) as f32;
input_tensor[1] = ((i >> 1) & 1) as f32;
label_tensor[0] = ((i & 1) ^ ((i >> 1) & 1)) as f32;
let mut run_args = SessionRunArgs::new();
run_args.add_target(&minimize);
let error_squared_fetch = run_args.request_fetch(&error_squared, 0);
run_args.add_feed(&input, 0, &input_tensor);
run_args.add_feed(&label, 0, &label_tensor);
session.run(&mut run_args)?;
Ok(run_args.fetch::<f32>(error_squared_fetch)?[0])
};
for i in 0..10000 {
train(i)?;
}
// ================
// Save the model.
// ================
saved_model_saver.save(&session, &g, &save_dir)?;
// ===================
// Evaluate the model.
// ===================
for i in 0..4 {
let error = train(i)?;
println!("Error: {}", error);
if error > 0.1 {
return Err(Box::new(Status::new_set(
Code::Internal,
&format!("Error too high: {}", error),
)?));
}
}
Ok(())
}
On a first sight cascadable method calls makes code noticeably less noisy and less intricate, hence, much easier to skim through. But that's not the biggest motivation to add this syntax: the real value is that in the presence of it the .
operator and mut
annotations became much simpler to reason about: the first started primarily indicating a possible change of expression's return type while the second started primarily indicating a shared state among non-sequentially arranged expressions — which is essentially the most useful information that we could squeeze from them, furthermore, by itself cascadable method calls encapsulates mutability without requiring any extra effort and creates a cascade of mutations.
All together yields a "tight system" where all parts complement each other and nothing could go wrong. What truly matters, this system must completely remove all the FUD associated with fluent interface pattern usage which plagues it since the beginning of its invention e.g. this Stack Overflow thread exposes the real source of that. In addition, with it we can be sure that mutpocalypse will never happen again because mut
would become much less overused thus we will have much less valid reasons to dislike it. And since the system will be more reliable, predictable, and requiring less maintenance that should make only positive impact on developer's productivity.
So, let take a look from a slightly different perspective. Another very demonstrative but rather not convincing example is this:
// The example was built on code from
// github.com/bitshifter/glam-rs/blob/20fe1be60caf99f/benches/support/mod.rs#L10
impl PCG32 {
pub fn seed(initstate: u64, initseq: u64) -> Self {
(PCG32 {
state: 0,
inc: (initseq << 1) | 1,
} )
next_u32 // https://crates.io/crates/tap
.tap_mut(|x| x.state wrapping_add (initstate))
next_u32
}
pub fn next_u32(&mut self) -> u32 {
let xorshifted = ((self.state >> 18) ^ self.state) >> 27;
let rot = self.state >> 59;
self.state
wrapping_mul (6364136223846793005)
wrapping_add (self.inc | 1);
((xorshifted >> rot) | (xorshifted << ((rot) wrapping_neg & 31))) as u32
}
}
Original piece of code
impl PCG32 {
pub fn seed(initstate: u64, initseq: u64) -> Self {
let mut rng = PCG32 {
state: 0,
inc: (initseq << 1) | 1,
};
rng.next_u32();
rng.state = rng.state.wrapping_add(initstate);
rng.next_u32();
rng
}
pub fn next_u32(&mut self) -> u32 {
let oldstate = self.state;
self.state = oldstate
.wrapping_mul(6364136223846793005)
.wrapping_add(self.inc | 1);
let xorshifted = ((oldstate >> 18) ^ oldstate) >> 27;
let rot = oldstate >> 59;
((xorshifted >> rot) | (xorshifted << (rot.wrapping_neg() & 31))) as u32
}
}
Almost every expression here allows to say something about the proposed syntax:
- The
PCG32
constructor is wrapped in cascadable parentheses with distinguishable extra whitespace at the end because cascadable method calls aren't allowed after braces otherwise expressions likeif x {} y ()
would be ambiguous — fortunately, this doesn't cause any trouble on practice - The
next_u32
method call lacks()
parentheses because there's no need in disambiguating between method and field namespaces; in this way it also looks better — almost like postfix operator - The
tap_mut
usage means that in the presence of cascadable method call there would be much higher demand for something like that with the necessity to break the chain becoming more unpleasant; although here it's possible to take a better approach which will be described a bit further - The order of expressions in
next_u32
method is also a bit more logical in comparison with original — with cascadable method calls we're able to get rid of extraoldstate
entity -
wrapping
operators became much closer to their overflowing counterparts and what's interesting we use them as implying compound assignment underneath - The
(rot) wrapping_neg
operand isn't mutable because it's wrapped in parentheses and that syntax is special — it moves/copies the thing into cascade with turning it into a local value
A lot of new information! But here's more to add:
-
Cascadable parentheses looks beautifully consistent in mathematical expressions:
let x = (x) sin; let y = (y / 2) cos; let z = (x + y - z) wrapping_add (4) tan; // In current Rust: let x = x.sin(); let y = (y / 2).cos(); let z = (x + y - z).wrapping_add(4).tan();
-
As a future possibility we may provide a first-class language support for tapping (however, this feature is so powerful that it certainly deserves a separate proposal and we shouldn't focus on it too much here):
pub fn seed(initstate: u64, initseq: u64) -> Self { (PCG32 { state: 0, inc: (initseq << 1) | 1, } ) next_u32 also (super.state wrapping_add (initstate)) next_u32 }
And next will be the last example. It shows that providing and using eDSLs could become extremely convenient with cascadable method calls with experience being very close to syntax-sugar-rich languages:
// The example was built on code from
// github.com/flutter/gallery/blob/a3838/lib/demos/material/tabs_demo.dart#L164
#[override]
fn build(&self, context: &BuildContext) -> impl Widget {
let tabs = [
GalleryLocalizations::of(context).colorsRed,
GalleryLocalizations::of(context).colorsOrange,
GalleryLocalizations::of(context).colorsGreen,
];
Scaffold::new()
app_bar (
AppBar::new()
automatically_imply_leading (false)
title (
Text::from(
GalleryLocalizations::of(context).demo_tabs_non_scrolling_title
) )
bottom (
TabBar::new()
controller (self.tab_controller)
is_scrollable (false)
also (
// we may use `tap` here
for tab in tabs {
super tab (Tab::new() text (tab))
} )
, )
, )
body (
TabBarView::new()
controller (self.tab_controller)
also (
for tab in tabs {
super child (Center::new() child (Text::from(tab)))
} )
, )
, )
}
Original piece of code
@override
Widget build(BuildContext context) {
final tabs = [
GalleryLocalizations.of(context).colorsRed,
GalleryLocalizations.of(context).colorsOrange,
GalleryLocalizations.of(context).colorsGreen,
];
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(
GalleryLocalizations.of(context).demoTabsNonScrollingTitle,
),
bottom: TabBar(
controller: _tabController,
isScrollable: false,
tabs: [
for (final tab in tabs) Tab(text: tab),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
for (final tab in tabs)
Center(
child: Text(tab),
),
],
),
);
}
Such Rust code should be truly compact, modular, inspectable, fast to compile and should play very nice with developing tools — that's why macros aren't nearly a close alternative to cascadable eDSLs. Also, it's by no means possible to achieve the same result with builder pattern, since with cascadable eDSLs we don't need to think whether &mut self
or Self
must be returned from setters as well as we don't need to worry whether snippets like x = x.with_y()
will cause problems downstream. Ultimately, no other similar syntax allows to interwine language constructs so closely together and give the same amount of useful guarantees or reveal the same amount of relevant information without turning it into a complete mess.
So, these were only the most important points; there's so much could be said about this syntax e.g. that x add (1)
could be used for incrementing numbers, that condition not
and cond bitxor (true)
could be used to flip booleans (!), that almost all mutations could become explicit, and that Rust indeed is expected to become easier to learn; a lot of questions about desugaring, prior art, and interaction with the rest of the language remain still to answer. It's hard to fit everything into a single post, hence, I've compiled a really big pre-RFC document that certainly should be reviewed by someone before being published to a wider auditory — I'm struggle with writing a lot, still some important parts might be missing, and at this point it's really hard to predict what community response it might cause in its current state.
Particularly these remains the biggest concerns:
- How much burden cascadable method call will create for being parsed properly
- In rust compiler
- In rust-analyzer and other de-facto standard tools
- In websites and text editors that aren't so tightly integrated with the language
- How will people perceive it
- It may have a potential of being confusing much higher than is expected
- No one may like changing their code to comply with it
- It isn't obvious to what degree weird the syntax is
- How hard it would be to provide a proper IDE experience
- Without "power of dot" autocompletion can be the hardest part
- An extra formatting assistance might be also required
- Autoformatters must be aware of it
- How it would interact with metaprogramming
- There are some precedence issues in macros
- With it previously valid macros may issue a lot of warnings
That said, I want to gather some constructive feedback in this thread and then if it would be mostly positive to find someone willing to review my RFC, help with rewording sentences if something is unclear, fixing grammar errors etc. Besides of that the majority of work (without implementation) is done and design of the feature is complete. Whatever the outcome would be, at least now we will have the most practical way to implement method cascading in Rust and some problems in its current design will be better expressed; also, the same syntax might be considered for implementation in other languages as well.