[Pre-RFC] Elvis/coalesce + ternary operator

Okay, there are 5 snippets from ripgrep crate, with different representations:


1

imperative (original)

/// Returns the relevant CPU features enabled at compile time.
fn compile_cpu_features() -> Vec<&'static str> {
    let mut features = vec![];
    if cfg!(feature = "simd-accel") {
        features.push("+SIMD");
    } else {
        features.push("-SIMD");
    }
    if cfg!(feature = "avx-accel") {
        features.push("+AVX");
    } else {
        features.push("-AVX");
    }
    features
}

declarative (current Rust)

/// Returns the relevant CPU features enabled at compile time.
fn compile_cpu_features() -> Vec<&'static str> {
    vec![
        if cfg!(feature = "simd-accel") { "+SIMD" } else { "-SIMD" },
        if cfg!(feature = "avx-accel") { "+AVX" } else { "-AVX" },
    ]
}

declarative (proposal)

/// Returns the relevant CPU features enabled at compile time.
fn compile_cpu_features() -> Vec<&'static str> {
    vec![
        cfg!(feature = "simd-accel") && "+SIMD" or "-SIMD",
        cfg!(feature = "avx-accel") && "+AVX" or "-AVX",
    ]
}

My opinion is that the latter has better semantics, since it reads more like ā€œvec of [feature, …]ā€ and less like ā€œvec of [expression returning feature, …]ā€.

It’s more ā€œcompressedā€ and less ā€œflexibleā€, but on this snippets I think that’s not required.


2

imperative (original)

let hash = match revision_hash.or(option_env!("RIPGREP_BUILD_GIT_HASH")) {
    None => String::new(),
    Some(githash) => format!(" (rev {})", githash),
};

declarative + lazy (current Rust)

let hash = revision_hash.or_else(|| option_env!("RIPGREP_BUILD_GIT_HASH"))
    .map(|githash| format!(" (rev {})", githash))
    .unwrap_or_else(String::new);

declarative + lazy (proposal)

let hash = (revision_hash or option_env!("RIPGREP_BUILD_GIT_HASH"))
    .map(|githash| format!(" (rev {})", githash))
    or String::new();

I think that the last is better, because it encourages (in theory) writing lazy evaluating code by default.

But it could be a bit hard to read due to braces on the first line.
As a possible improvement we could consider a syntax similar to the following:

let hash = revision_hash or { option_env!("RIPGREP_BUILD_GIT_HASH") }
    .map(|githash| format!(" (rev {})", githash))
    or { String::new() };

3

imperative (original)

if early_matches.is_present("trace") {
    log::set_max_level(log::LevelFilter::Trace);
} else if early_matches.is_present("debug") {
    log::set_max_level(log::LevelFilter::Debug);
} else {
    log::set_max_level(log::LevelFilter::Warn);
}

declarative (current Rust)

log::set_max_level(
    if early_matches.is_present("trace") {
        log::LevelFilter::Trace
    } else if early_matches.is_present("debug") {
        log::LevelFilter::Debug
    } else {
        log::LevelFilter::Warn
    });

declarative (proposal)

log::set_max_level(
    early_matches.is_present("trace") && log::LevelFilter::Trace
    or early_matches.is_present("debug") && log::LevelFilter::Debug
    or log::LevelFilter::Warn
);

The last example seems to be cleaner, since it has ā€œmap condition to typeā€ semantics, and each ā€œmappingā€ is placed on its own line. However, this semantics is not really explicit. But I think that this could be fixed with (optional?) leading or:

log::set_max_level(
    or early_matches.is_present("trace") && log::LevelFilter::Trace
    or early_matches.is_present("debug") && log::LevelFilter::Debug
    or log::LevelFilter::Warn
);

4

declarative (original)

Ok(if self.matches().is_present("type-list") {
    Command::Types
} else if self.matches().is_present("files") {
    if one_thread {
        Command::Files
    } else {
        Command::FilesParallel
    }
} else if self.matches().can_never_match(self.patterns()) {
    Command::SearchNever
} else if one_thread {
    Command::Search
} else {
    Command::SearchParallel
})

declarative (proposal)

Ok( 
    self.matches().is_present("type-list") && Command::Types
    or self.matches().is_present("files") && (one_thread && Command::Files or Command::FilesParallel)
    or self.matches().can_never_match(self.patterns()) && Command::SearchNever
    or one_thread && Command::Search
    or Command::SearchParallel
)

Personally I don’t like nesting here. But I think that this code is still readable, and leading or also could improve this example:

Ok( 
    or self.matches().is_present("type-list") && Command::Types
    or self.matches().is_present("files") && (one_thread && Command::Files or Command::FilesParallel)
    or self.matches().can_never_match(self.patterns()) && Command::SearchNever
    or one_thread && Command::Search
    or Command::SearchParallel
)

5

declarative (original)

pub fn stdout(&self) -> cli::StandardStream {
    let color = self.matches().color_choice();
    if self.matches().is_present("line-buffered") {
        cli::stdout_buffered_line(color)
    } else if self.matches().is_present("block-buffered") {
        cli::stdout_buffered_block(color)
    } else {
        cli::stdout(color)
    }
}

declarative (proposal)

pub fn stdout(&self) -> cli::StandardStream {
    let color = self.matches().color_choice();
    self.matches().is_present("line-buffered") && cli::stdout_buffered_line(color)
    or self.matches().is_present("block-buffered") && cli::stdout_buffered_block(color)
    or cli::stdout(color)
}

I’d say that the second example also represents a case where code with or would be more straightforward. Leading or version:

pub fn stdout(&self) -> cli::StandardStream {
    let color = self.matches().color_choice();
    or self.matches().is_present("line-buffered") && cli::stdout_buffered_line(color)
    or self.matches().is_present("block-buffered") && cli::stdout_buffered_block(color)
    or cli::stdout(color)
}

I don't know what other Rust programmers would think but it looks really alien to me. And this is what I meant in my previous comment for "swiss army knife": we can add features to Rust, but we should aware doing too much such that it becomes completely another language.

Rust programmers would more like

log::set_max_level(
    pmatch {
        early_matches.is_present("trace") => log::LevelFilter::Trace,
        early_matches.is_present("debug") => log::LevelFilter::Debug,
        _ => log::LevelFilter::Warn,
    }     
);

where pmatch means "predicates match"?

3 Likes

In Kotlin this construct is called when. (Side disgression: wow, it took me forever to write this tiny example; my Kotlin has atrophied because of my adoption of Rust for personal use.)

val level = when {
    early_matches.is_present("trace") -> LevelFilter.Trace
    early_matches.is_present("debug") -> LevelFilter.Debug
    else -> LevelFilter.Warn
}

I’ve actually written and used a small macro to do this a few times:

#[macro_export]
macro_rules! when {
    {
        $($antecedent:expr => $consequent:expr,)*
        $(_ => $otherwise:expr,)*
    } => {
        match () {
            $(_ if $antecedent => $consequent,)*
            $(_ => $otherwise)*
        }
    };
}

I’ve never really felt the need to publish such a small macro, but maybe it could see usage. (match could not be extended to cover this functionality, as a block directly after match is a block producing the value to be matched upon, unlike Kotlin where the arg is always parenthesized.)

2 Likes

I would say if when is a keyword it can work the revered way as match

when Some(t) {
    = expression1(...) => function1(t),
    = expression2(...) => function2(t),
    _ => fallback()
}

This cannot be implemented in a macro I think.

Or we can reuse the if let and if construct:

if let Some(t) {
    = expression1(...) => function1(t),
    = expression2(...) => function2(t),
} else {
    fallback()
}

if {
   predicate1(...) => value1,
   predicate2(...) => value2,
} else {
   fallback
}

Honestly, in case 1 and 2, the proposed solution isn’t even shorter, and in all cases, the current approach with if looks just fine, and the ā€œand-orā€ syntax looks really weird. I can imagine it’s somewhat clearer to someone else, but even then there doesn’t seem to be so much advantage that it would warrant adding a core language construct at all.

By the way, if you really have lots of complicated logic with nesting and conditionals, then you should probably start pulling those out in functions instead of trying to compress the code by using specialized expressions. You will make the life of future maintainers of the code (probably including future yourself) much easier.

(Incidentally, case 2 which I have encountered quite often too, could be shortened with the use of an map_or_default method on Option, which I think several Rust users asked for in the past too. It would have the advantage that it’s just a new method in the standard library, it’s basically trivial to implement, doesn’t need more compiler support, and consequently it wouldn’t need more testing with regards to interaction with other core language elements. Read: less compiler bugs.)

7 Likes

To me, the (ab)use of and/or for ā€œtheā€ ternary operator here screams ā€œtruthyā€ coersions (and dynamic types) to me, and I think that’s why I’m so biased against this. I think (though don’t have the formalism to show) this would require or to be a keyword as well, so if so that makes this specific syntax a complete nonstarter.

I think the answer is to make the verbs usable to work with Try types (Result and Option mainly) more consistent where possible, rather than introduce new syntax. And maybe a macro for condensing if-else-if chains into a match-like shape.

2 Likes

This largely feels to me like

x >= 0 || panic!("Must be positive");

which works today, but isn’t something I’d suggest anyone ever do.

I definitely prefer the if / else if / else to the && or && or stuff.

2 Likes

This code works because ! can be coerce to any type, including bool. But this is very restricted case.

1 Like

The nested line looks really weird to me. Worse, if you're reading this code sequentially, you never know whether you're reading the final value or a condition, which requires some lookahead.

3 Likes

I’m not seeing value in this. I see the ternary operator as a wart caused by unfortunate statement vs expression distinction existing in other languages. I like that Rust has only if and it works everywhere just fine thanks to (almost) everything being an expression in Rust.

The syntax of if let Some() and required {} is sometimes a bit much. However, when I program in Swift that has much more syntax sugar around unwrapping, I don’t like the syntax sugar that much. These are just two options on the trade-off between ease of writing and clarity of reading, and neither is clearly better IMHO.

5 Likes

I agree with all critique against && operator in ternary context.
Let there be the following syntax instead:

/// Returns the relevant CPU features enabled at compile time.
fn compile_cpu_features() -> Vec<&'static str> {
    vec![
        cfg!(feature = "simd-accel") <- "+SIMD" or "-SIMD",
        cfg!(feature = "avx-accel") <- "+AVX" or "-AVX",
    ]
}
log::set_max_level(
    or early_matches.is_present("trace") <- log::LevelFilter::Trace
    or early_matches.is_present("debug") <- log::LevelFilter::Debug
    or log::LevelFilter::Warn
);
Ok(
    or self.matches().is_present("type-list") <- Command::Types
    or self.matches().is_present("files") <- { one_thread <- Command::Files or Command::FilesParallel }
    or self.matches().can_never_match(self.patterns()) <- Command::SearchNever
    or one_thread <- Command::Search
    or Command::SearchParallel
)
pub fn stdout(&self) -> cli::StandardStream {
    let color = self.matches().color_choice();
    or self.matches().is_present("line-buffered") <- cli::stdout_buffered_line(color)
    or self.matches().is_present("block-buffered") <- cli::stdout_buffered_block(color)
    or cli::stdout(color)
}

This syntax could be better than previous because each operator now has its own meaning.
And A <- B construct would be useful by its own:

// Current Rust
let path = env::var_os("XDG_CONFIG_HOME")
    .and_then(|x| if x.is_empty() { None } else { Some(PathBuf::from(x)) })

// Proposal
let path = env::var_os("XDG_CONFIG_HOME")
    .and_then(|x| !x.is_empty() <- PathBuf::from(x)))

Such coersion would be possible on booleans, integers, optional and result types.
It would be backed by the following trait:

trait Coerse {
    fn bin(&self) -> bool;
}

And on desugaring side:

let a = b <- c;
/*
let a = if Coerse::bin(&b) { Some(c) } else { None };
*/

Also, if trailing or would be allowed we would be able to use it to hold indentation in complex boolean expressions.
Of course we can extract functions/variables, but IMO that not always that leads into a better code.

Consider this dummy snippet (I don’t know where to search for real-world equivalent):

/// Current Rust
fn example() -> bool {
    foo() && bar()
        || baz() && qux() && quux() && ((
                corge() && grault()
                || garply()) &&
            waldo())
        || fred() && plug()
}

We can expand it to reveal control flow:

/// Proposal
fn example() -> bool {
    (
        or foo()
        && bar()
    ||
        or baz()
        && qux()
        && quux()
        && (
            or corge()
            && grault()
        ||
            or garply()
        )
        && waldo()
    ||
        or fred()
        && plugh()
    )
}

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.