Some languages have elvis operator which allows to coalesce None
values like this:
let value = option_a ?: option_b ?: fallback;
There has been proposal to add it in Rust which resolved with summary:
- Elvis could be extended to work on every
Try
type- Supporting only
Option
is kind of weak
- Supporting only
- It would be nice if there was a way to access an error value
- Or rather to report its existence to user
- People felt that a more general solution is needed
- Which resulted in recently stabilized
let-else
expression
- Which resulted in recently stabilized
But that thread as well as let-else
were mostly about having control flow like return
, continue
on the RHS.
This thread is mostly about providing fallback and introspecting errors (which let-else
doesn't allow).
Summary
This alternative to elvis operator covers significantly more use cases.
It "kind of" makes sense in Rust because the syntax remains a bit noisy and perhaps intricate, nevertheless, I've spent a lot of time on it, the result is interesting, and there might be some room for improvement.
Please, treat this as a report of research rather than as a serious proposal.
Syntax
The syntax consists from a special scope which opens with case
and closes with `ident
without semicolon at the end. This scope contains an expression which must contain a special ~
operator (or many of them).
All together looks more or less like this:
case expression_with~.operators~ `ident
Minimal usage example
// Current Rust
let result = something
.unwrap_or_else(|e| fallback(e));
// With this syntax
let result = {
case something~ `e
fallback(e)
};
*This example isn't very interesting — make sure to review real-world examples provided further.
Mechanics
-
When the
case
expression executes successfully its resulting value is yielded otherwise error is catched- Where:
-
successfully means that it didn't early returned and the
~
operator didn't catched any error -
yielded means that the
case
expression value becomes the result of the outer scope -
catched means that error is bound to backtick identifier and remains available in the current scope
-
- Where:
-
The
~
operator is like?
but catches errors forward instead of yeeting them outside
Advanced examples
Below is example from my crate:
// Current Rust
let swayipc::Node { app_id, window_properties, .. } = focused_window;
let focused_application = app_id
.or_else(|| window_properties
.and_then(|p| p.instance
.or(p.class)
.or(p.title)));
if let Some((x_offset, y_offset)) = offsets
.get(focused_application
.as_deref()
.unwrap_or("none")
)
{
x += x_offset;
y += y_offset;
}
// With this syntax
let swayipc::Node { app_id, window_properties, .. } = focused_window;
let focused_application = {
case &app_id~ `e1
case &window_properties~.instance~ `e2
case &window_properties~.class~ `e3
case &window_properties~.title~ `e4
"none"
};
if let Some((x_offset, y_offset)) = offsets
.get(focused_application)
{
x += x_offset;
y += y_offset;
}
And another one:
// Current Rust
fn default_config_path() -> Option<String> {
use std::path::PathBuf;
let page_home = std::env::var("XDG_CONFIG_HOME")
.map(|xdg_config_home| {
PathBuf::from(xdg_config_home)
.join("page")
});
let page_home = page_home.or_else(|_| std::env::var("HOME")
.map(|home| {
PathBuf::from(home)
.join(".config/page")
}));
log::trace!(target: "config", "directory is: {page_home:?}");
let Ok(page_home) = page_home else {
return None;
};
let init_lua = page_home
.join("init.lua");
if init_lua.exists() {
let p = init_lua.to_string_lossy().to_string();
log::trace!(target: "config", "use init.lua");
return Some(p)
}
let init_vim = page_home
.join("init.vim");
if init_vim.exists() {
let p = init_vim.to_string_lossy().to_string();
log::trace!(target: "config", "use init.vim");
return Some(p)
}
None
}
// With this syntax
fn default_config_path() -> Option<String> {
use std::path::PathBuf;
let page_home = {
case PathBuf::from(std::env::var("XDG_CONFIG_HOME")~)
.join("page") `e1
case PathBuf::from(std::env::var("HOME")~)
.join(".config/page") `e2
return None
};
log::trace!(target: "config", "directory is: {page_home:?}");
let init_lua = page_home
.join("init.lua");
if init_lua.exists() {
let p = init_lua.to_string_lossy().to_string();
log::trace!(target: "config", "use init.lua");
return Some(p)
}
let init_vim = page_home
.join("init.vim");
if init_vim.exists() {
let p = init_vim.to_string_lossy().to_string();
log::trace!(target: "config", "use init.vim");
return Some(p)
}
None
}
As we see, it's not only an alternative to elvis but also quite a viable alternative to combinators on Try
types without restrictions of closures and extra nesting!
Another observation we can take is that it's fine to have unused error bindings — that's because there's no support for _
which would look quite ugly with this syntax.
Also, compiler will warn when one error binding shadows another, so e1
, e2
etc is a mandatory style.
Special case for Option
binding
Let take a look at the following example from wluma:
// Current Rust
pub fn new(
base_path: &str,
thresholds: HashMap<u64, String>
) -> Result<Self, Box<dyn Error>> {
Path::new(base_path)
.read_dir()
.ok()
.and_then(|dir| {
dir.filter_map(|e| e.ok())
.find(|e| {
fs::read_to_string(e.path().join("name"))
.unwrap_or_default()
.trim()
== "als"
})
.and_then(|e| {
parse_illuminance(e.path())
.or_else(|_| parse_intensity(e.path()))
.ok()
})
})
.map(|sensor| Self { sensor, thresholds })
.ok_or_else(|| "No iio device found".into())
}
// With this syntax
pub fn new(
base_path: &str,
thresholds: HashMap<u64, String>
) -> Result<Self, Box<dyn Error>> {
case {
let path = Path::new(base_path)
.read_dir()~
.filter_map(|e| e.ok())
.find(|e| {
fs::read_to_string(e.path().join("name"))
.unwrap_or_default()
.trim()
== "als"
})~;
let sensor = {
case parse_illuminance(path.path())~ `e1
case parse_intensity(path.path())~ `e2
};
Ok(Self { sensor, thresholds })
} `e
Err("No iio device found").into()
}
Important is the let sensor = {..}
part: it demonstrates that when the case
parent scope evaluates to ()
we make the parent scope evaluating to Option
as if fallback had been None
and every case
expression on the same level had been wrapped in Some
.
In other words it's a shortcut for making something like this:
let sensor = {
case Some(parse_illuminance(path.path())~) `e1
case Some(parse_intensity(path.path())~) `e2
None
};
We detect that parent scope evaluates to ()
only by the presence of ;
at its end or by the absence of expression after `ident
, that said, there's no type-dependent desugaring hence something like drop(e)
as fallback will require case
expressions to evaluate into ()
.
As an alternative to try
blocks
Something like this:
let result = try {
try_a?;
try_b?;
ok
};
Can easily become:
let result = {
case {
try_a~;
try_b~;
Ok(ok)
} `e
Err(e)
};
It's not very ergonomic but I think it's completely fine considering that usages of this snippet would be relatively rare. Moreover, what is the point in returning Result
when we can readily pattern match on it?
let value = {
case {
try_a~;
try_b~;
ok
} `e
process(e)
};
handle(value)
Desugaring
Desugaring seems to be very simple.
Let begin with the following snippet:
let result = {
case something~ `e
process(e)
};
First when case
appears in some scope then label 'a:
is added to that scope:
let result = 'a: {
case something~ `e
process(e)
};
Next case
expression replaces with regular scope labeled with 'b:
:
let result = 'a: {
let e = 'b: { something~ };
process(e)
};
Then expression inside of that scope becomes prefixed with break 'a
:
let result = 'a: {
let e = 'b: {
break 'a {
something~
}
};
process(e)
};
While the ~
operator desugars as ?
but with break 'b
inside:
let result = 'a: {
let e = 'b: {
break 'a {
match Try::branch(something) {
ControlFlow::Continue(v) => v,
ControlFlow::Break(r) => break 'b FromResidual::from_residual(r),
}
}
};
process(e)
};
Through, there's a bit different desugaring when error binding isn't used:
let result = 'a: {
'b: {
break 'a {
match Try::branch(something) {
ControlFlow::Continue(v) => v,
ControlFlow::Break(_ignored) => break 'b,
}
}
};
process()
};
And lastly, if parent scope results in ()
then None
and Some(_)
are added:
let result = 'a: {
'b: {
break 'a {
match Try::branch(something) {
ControlFlow::Continue(v) => Some(v),
ControlFlow::Break(_ignored) => break 'b,
}
}
};
None
};
More real-world examples
From smithay
// Current Rust
fn fullscreen_output_geometry(
wl_surface: &WlSurface,
wl_output: Option<&wl_output::WlOutput>,
space: &mut Space<Window>,
) -> Option<Rectangle<i32, Logical>> {
// First test if a specific output has been requested
// if the requested output is not found ignore the request
wl_output
.and_then(Output::from_resource)
.or_else(|| {
let w = space
.elements()
.find(|window| window.toplevel().wl_surface() == wl_surface)
.cloned();
w.and_then(|w| space.outputs_for_element(&w).get(0).cloned())
})
.and_then(|o| space.output_geometry(&o))
}
// With this syntax
fn fullscreen_output_geometry(
wl_surface: &WlSurface,
wl_output: Option<&wl_output::WlOutput>,
space: &mut Space<Window>,
) -> Option<Rectangle<i32, Logical>> {
// First test if a specific output has been requested
// if the requested output is not found ignore the request
case {
let o = Output::from_resource(wl_output)~;
space.output_geometry(&o)~
} `e1
case {
let w = &space
.elements()
.find(|window| window.toplevel().wl_surface() == wl_surface)
.cloned()~;
let o = space
.outputs_for_element(w)
.get(0)
.cloned()~;
space.output_geometry(&o)~
} `e2
}
Another one:
// Current Rust
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
let seat: Seat<AnvilState<BackendData>> = Seat::from_resource(&seat).unwrap();
let kind = PopupKind::Xdg(surface);
if let Some(root) = find_popup_root_surface(&kind).ok().and_then(|root| {
self.space
.elements()
.find(|w| w.toplevel().wl_surface() == &root)
.cloned()
.map(FocusTarget::Window)
.or_else(|| {
self.space
.outputs()
.find_map(|o| {
let map = layer_map_for_output(o);
map.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL).cloned()
})
.map(FocusTarget::LayerSurface)
})
}) {
let ret = self.popups.grab_popup(root, kind, &seat, serial);
if let Ok(mut grab) = ret {
if let Some(keyboard) = seat.get_keyboard() {
if keyboard.is_grabbed()
&& !(keyboard.has_grab(serial)
|| keyboard.has_grab(grab.previous_serial().unwrap_or(serial)))
{
grab.ungrab(PopupUngrabStrategy::All);
return;
}
keyboard.set_focus(self, grab.current_grab(), serial);
keyboard.set_grab(PopupKeyboardGrab::new(&grab), serial);
}
if let Some(pointer) = seat.get_pointer() {
if pointer.is_grabbed()
&& !(pointer.has_grab(serial)
|| pointer.has_grab(grab.previous_serial().unwrap_or_else(|| grab.serial())))
{
grab.ungrab(PopupUngrabStrategy::All);
return;
}
pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep);
}
}
}
}
// With this syntax
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
let seat: Seat<AnvilState<BackendData>> = Seat::from_resource(&seat).unwrap();
let kind = PopupKind::Xdg(surface);
let focus_target = {
case {
let root = find_popup_root_surface(&kind)~;
let el = self.space.elements()
.find(|w| w.toplevel().wl_surface() == &root)
.cloned()~;
FocusTarget::Window(el)
} `e1
case {
let outp = self.space.outputs()
.find_map(|o| {
let map = layer_map_for_output(o);
map.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)
.cloned()
})~;
FocusTarget::LayerSurface(outp)
} `e2
};
if let Some(root) = focus_target {
let ret = self.popups.grab_popup(root, kind, &seat, serial);
if let Ok(mut grab) = ret {
if let Some(keyboard) = seat.get_keyboard() {
if keyboard.is_grabbed()
&& !(keyboard.has_grab(serial)
|| keyboard.has_grab({
case grab.previous_serial())~ `e
serial
}))
{
grab.ungrab(PopupUngrabStrategy::All);
return;
}
keyboard.set_focus(self, grab.current_grab(), serial);
keyboard.set_grab(PopupKeyboardGrab::new(&grab), serial);
}
if let Some(pointer) = seat.get_pointer() {
if pointer.is_grabbed()
&& !(pointer.has_grab(serial)
|| pointer.has_grab({
case grab.previous_serial()~ `e
grab.serial()
}))
{
grab.ungrab(PopupUngrabStrategy::All);
return;
}
pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep);
}
}
}
}
From alacritty
// Current Rust
pub fn load(options: &Options) -> UiConfig {
let config_options = options.config_options.clone();
let config_path = options.config_file.clone().or_else(installed_config);
// Load the config using the following fallback behavior:
// - Config path + CLI overrides
// - CLI overrides
// - Default
let mut config = config_path
.as_ref()
.and_then(|config_path| load_from(config_path, config_options.clone()).ok())
.unwrap_or_else(|| {
let mut config = UiConfig::deserialize(config_options).unwrap_or_default();
match config_path {
Some(config_path) => config.config_paths.push(config_path),
None => info!(target: LOG_TARGET_CONFIG, "No config file found; using default"),
}
config
});
after_loading(&mut config, options);
config
}
// With this syntax
pub fn load(options: &Options) -> UiConfig {
let config_options = options.config_options.clone();
let config_path = {
case options.config_file.clone()~ `e1
case installed_config()~ `e2
};
// Load the config using the following fallback behavior:
// - Config path + CLI overrides
// - CLI overrides
// - Default
let mut config = {
case load_from(config_path.as_ref()~, config_options.clone())~ `e
let mut config = UiConfig::deserialize(config_options).unwrap_or_default();
match config_path {
Some(config_path) => config.config_paths.push(config_path),
None => info!(target: LOG_TARGET_CONFIG, "No config file found; using default"),
}
config
};
after_loading(&mut config, options);
config
}
Another one:
// Current Rust
#[cfg(not(windows))]
fn installed_config() -> Option<PathBuf> {
// Try using XDG location by default.
xdg::BaseDirectories::with_prefix("alacritty")
.ok()
.and_then(|xdg| xdg.find_config_file("alacritty.yml"))
.or_else(|| {
xdg::BaseDirectories::new()
.ok()
.and_then(|fallback| fallback.find_config_file("alacritty.yml"))
})
.or_else(|| {
if let Ok(home) = env::var("HOME") {
// Fallback path: ~HOME/.config/alacritty/alacritty.yml.
let fallback = PathBuf::from(&home).join(".config/alacritty/alacritty.yml");
if fallback.exists() {
return Some(fallback);
}
// Fallback path: ~HOME/.alacritty.yml.
let fallback = PathBuf::from(&home).join(".alacritty.yml");
if fallback.exists() {
return Some(fallback);
}
}
None
})
}
// With this syntax
#[cfg(not(windows))]
fn installed_config() -> Option<PathBuf> {
// Try using XDG location by default.
case xdg::BaseDirectories::with_prefix("alacritty")~
.find_config_file("alacritty.yml")~ `e1
case xdg::BaseDirectories::new()~
.find_config_file("alacritty.yml")~ `e2
let home = env::var("HOME").ok()?;
// Fallback path: ~HOME/.config/alacritty/alacritty.yml.
let fallback = PathBuf::from(&home)
.join(".config/alacritty/alacritty.yml");
case fallback.exists()
.then_some(fallback)~ `e3
// Fallback path: ~HOME/.alacritty.yml.
let fallback = PathBuf::from(&home)
.join(".alacritty.yml");
case fallback.exists()
.then_some(fallback)~ `e4
}
And yet another one:
// Current Rust
fn commit_hash() -> Option<String> {
Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|hash| hash.trim().into())
}
// With this syntax
fn commit_hash() -> Option<String> {
case {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()~;
output.status.success()
.then(())~;
String::from_utf8(output.stdout)
.trim()
} `e
}
From druid
// Current Rust
pub extern "C" fn set_marked_text(
this: &mut Object,
_: Sel,
text: id,
selected_range: NSRange,
replacement_range: NSRange,
) {
with_edit_lock_from_window(this, true, |mut edit_lock| {
let mut composition_range = edit_lock.composition_range().unwrap_or_else(|| {
// no existing composition range? default to replacement range, interpreted in absolute coordinates
// undocumented by apple, see
// https://github.com/yvt/Stella2/blob/076fb6ee2294fcd1c56ed04dd2f4644bf456e947/tcw3/pal/src/macos/window.rs#L1144-L1146
decode_nsrange(&*edit_lock, &replacement_range, 0).unwrap_or_else(|| {
// no replacement range either? apparently we default to the selection in this case
edit_lock.selection().range()
})
});
let replace_range_offset = edit_lock
.composition_range()
.map(|range| range.start)
.unwrap_or(0);
let replace_range = decode_nsrange(&*edit_lock, &replacement_range, replace_range_offset)
.unwrap_or_else(|| {
// default replacement range is already-exsiting composition range
// undocumented by apple, see
// https://github.com/yvt/Stella2/blob/076fb6ee2294fcd1c56ed04dd2f4644bf456e947/tcw3/pal/src/macos/window.rs#L1124-L1125
composition_range.clone()
});
let text_string = parse_attributed_string(&text);
edit_lock.replace_range(replace_range.clone(), text_string);
// Update the composition range
composition_range.end -= replace_range.len();
composition_range.end += text_string.len();
if composition_range.is_empty() {
edit_lock.set_composition_range(None);
} else {
edit_lock.set_composition_range(Some(composition_range));
};
// Update the selection
if let Some(selection_range) =
decode_nsrange(&*edit_lock, &selected_range, replace_range.start)
{
// preserve ordering of anchor and active
let existing_selection = edit_lock.selection();
let new_selection = if existing_selection.anchor < existing_selection.active {
Selection::new(selection_range.start, selection_range.end)
} else {
Selection::new(selection_range.end, selection_range.start)
};
edit_lock.set_selection(new_selection);
}
});
}
// With this syntax
pub extern "C" fn set_marked_text(
this: &mut Object,
_: Sel,
text: id,
selected_range: NSRange,
replacement_range: NSRange,
) {
with_edit_lock_from_window(this, true, |mut edit_lock| {
let mut composition_range = {
case edit_lock.composition_range()~ `e1
// no existing composition range? default to replacement range, interpreted in absolute coordinates
// undocumented by apple, see
// https://github.com/yvt/Stella2/blob/076fb6ee2294fcd1c56ed04dd2f4644bf456e947/tcw3/pal/src/macos/window.rs#L1144-L1146
case decode_nsrange(&*edit_lock, &replacement_range, 0)~ `e2
// no replacement range either? apparently we default to the selection in this case
edit_lock.selection().range()
});
let replace_range_offset = {
case edit_lock.composition_range()~.start `e 0
};
let replace_range = {
case decode_nsrange(&*edit_lock, &replacement_range, replace_range_offset)~ `e
// default replacement range is already-exsiting composition range
// undocumented by apple, see
// https://github.com/yvt/Stella2/blob/076fb6ee2294fcd1c56ed04dd2f4644bf456e947/tcw3/pal/src/macos/window.rs#L1124-L1125
composition_range.clone()
};
let text_string = parse_attributed_string(&text);
edit_lock.replace_range(replace_range.clone(), text_string);
// Update the composition range
composition_range.end -= replace_range.len();
composition_range.end += text_string.len();
if composition_range.is_empty() {
edit_lock.set_composition_range(None);
} else {
edit_lock.set_composition_range(Some(composition_range));
};
// Update the selection
if let Some(selection_range) =
decode_nsrange(&*edit_lock, &selected_range, replace_range.start)
{
// preserve ordering of anchor and active
let existing_selection = edit_lock.selection();
let new_selection = if existing_selection.anchor < existing_selection.active {
Selection::new(selection_range.start, selection_range.end)
} else {
Selection::new(selection_range.end, selection_range.start)
};
edit_lock.set_selection(new_selection);
}
});
}
Drawbacks
- This syntax is complicated and unusual
- But as well seems to be useful
- And doesn't seem to be more complicated than e.g.
try-catch
in other languages
- It consists from a new keyword and two operators
- Perhaps we can reuse
?
(questionmark) - And maybe it's possible to find another way to bind error
- Perhaps we can reuse
- It significantly complicates the language
- However, I believe learning curve of this syntax is lower than learning curve of combinators (it's like
async/.await
but forTry
) - Also, it allows some things that are hard to achieve with combinators
- However, I believe learning curve of this syntax is lower than learning curve of combinators (it's like
- It's quite noisy
- But combinators might be noisy and unreadable as well
- Perhaps custom fonts and proper syntax highlighting might help a bit
- It allows to write bad code easily
- Perhaps some lints that prevents
bool.then(())~
could fix that
- Perhaps some lints that prevents
- Combinators on
Result/Option
would be soft-deprecated - Due to large amount of code already written users would still be required to learn combinators in depth
- No familiarity with
?: