my-new-rust-binary-search
chadnauseam.com
chadnauseam.com
So, the function accepts two values that implement Mid trait. And there is a blanket implementation for Mid that applies to anything can be added, substracted, divided etc and can be derived from a u8. So now this will make every numeric type a Mid. (Which, as the author states only an example I guess)
I can read and understand how this works here. But when I’m in a larger code base, I can not find what trait implementation made what possible.
With all the trait bounds and implicit conversions happening around and macros implementing traits on stuff it becomes a shit stew in a hurry.
And everyone always reaches to the most clever way to do things. I mean, does Mid need to be a trait? I guess it works beautifully yeah. I’d prefer to pass a midpoint calculator function instead.
I guess this turned into a rant. I actually really like Rust the language but I think I don’t like Rust codebases basically.
It is the least readable language I’ve seen not because of the syntax but because of all the cleverness going on around the traits and macros.
And
> everyone always reaches to the most clever way to do things.
Yes oh yes
Rust was a brilliant idea spoilt by too much cleverness
I'm hoping for Rust II. A smaller language, with a bigger standard library and no dependence on run times.
By "no dependence on run times." I mean no async/await. It is an abomination in Rust
Out of curiosity, what alternative would you have preferred to see, if any?
I find it astounding that so many people think asynchronous programming means async/await
Asyc/await is a way of letting programmers who do not want to learn how, do asynchronous programming.
It has poisoned the well
I think that goes to taste.
> Event loop + callback hell
That was always due to indisciplined programming. Async/await offers up other hells.
I think it makes some sense in memory managed systems. But in a system with static compile time management like Rust async/await forces all sorts of compromises. Mostly these fall on library authors, but it is its own hellscape. `Pin`?
> great in some cases, but they suck in others
That is a universal refrain for software systems!
What approach are you thinking of for the API of those functions, since async/await is not something you'd like to see?
I don't think it's appropriate for the level of coding that Rust is targeting... But... It would be nice.
I am unfamiliar with GO.
I am thinking of the way the C library does it.
> I don't think it's appropriate for the level of coding that Rust is targeting
That would be low level system programming. In part
What way is that? I didn't think the C standard library had any understanding of blocking/non-blocking.
But in any case, I'm pretty sure Rust has supported the nonblocking functionality you want for quite a while now (maybe since 1.1.0)? You'll need to piece it together yourself and use the libc crate since it's a platform-specific thing, but it doesn't seem too horrendous. Ultimately it comes down to the fact that the fundamental method for the std:io::Read is
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
which is perfectly compatible with non-blocking reads. For example, std::io::File implements Read and std::os::fd::FromRawFd, so if you can get a non-blocking file descriptor you should have non-blocking reads for files.Not what I recommend. The C library does much better than that.
In asynchronous programming you can only deal with the data you have, so you buffer it. I have not counted bytes like that - for the purposes of reading from a file, in decades.
A fancy C library could buffer the partial read for you rather than you needing to do it, and it could even maybe deal with turning your function into the state machine required to resume the function at the partial read.
But then you look around and realise you've created another async/await.
E.g. Bevy implements a trait (QueryData) for tuples of up to 32 items (A,) + (A, B) + (A, B, C)...
If you went over that 32 items, you used to get a confusing error about not your type not implementing QueryData.
Now you get a nice error message explaining the common reasons why this your type does not satisfy the trait.
I ran into this confusing error so many times i just stopped doing blanket deref impls and instead just impl it for `Box<T>` directly. Doing that avoids the weird error entirely.
Still, i love Rust. However it, like any lang, is not perfect. Nor should it be.
I really wish Rust had a way to define “template style” generic functions rather than “trait style”. Some things simply can not be done gracefully with trait spaghetti.
It has! It's called proc macro [1]. I'm only half joking.
[1] It can generate anything, errors generally suck ass in it as much in C++ templates and it feels similarly brittle.
> “Advanced” Rust becomes as inscrutable as C++ template crap.
I think people are abusing traits a bit too much. They are very nifty and often compile to very optimized code.
That said this trait is not an example of such abuse. Having a trait to help with overloading is the most benign form of trait use.
Solution to grandparent's problem is called rust analyzer. It will show you what methods are usable on your variable.
But you’re not wrong :)
What's macro spaghetti?
However I 100% agree with you that traits make it really hard to figure out what actual function you’re calling. I wish there was a way to see something like “in your code, this generic function is called with types T1, T2, etc.” and see the trait implementations for those types as well. I’ve actually wanted to contribute this to rust-analyzer for a while.
Definitely agree about macros. A lot of crates use them extensively, but they are inherently less intuitive to reason about, tend to be less well documented and produce less helpful error messages when you use them incorrectly.
I think a lot of trait misuse probably stems from the fact that structs kind of look like classes and traits kind of look like interfaces, so people may try to do Java-style OOP with them.
(As an aside, the other thing that makes it difficult for me to grok Rust code is when everything is nested in 3+ levels of smart pointers.)
I can not find what trait implementation made what possible.
That's what your IDE is for? If I pull up the code docs for e.g. a call to an iterator function I see which object or trait that function came from.If you do sanity checks in Rust then you should panic instead of returning options. However, in this case you cannot do the sanity checks, because the contract of the function is that it will only call the predicate strictly between l and r (i.e., only on indices returned by mid). Further, given the initial sanity checks, the sanity checks inside the loop body are redundant, as the loop invariant ensures that those checks never fail.
[1] https://julesjacobs.com/notes/binarysearch/binarysearch.pdf [2] https://byorgey.wordpress.com/2023/01/01/competitive-program...
Here is a Rust implementation of the original:
pub fn search<T: Mid, G>(predicate: G, mut l: T, mut r: T) -> (T, T)
where G: Fn(&T) -> bool
{
loop {
match l.mid(&r) {
None => return (l, r),
Some(m) => {
if predicate(&m) { r = m }
else { l = m }
}
}
}
}
Or, traitless: pub fn search<T, H, G>(mid: H, predicate: G, mut l: T, mut r: T) -> (T, T)
where H: Fn(&T,&T) -> Option<T>, G: Fn(&T) -> bool
{
loop {
match mid(&l, &r) {
None => return (l, r),
Some(m) => {
if predicate(&m) { r = m }
else { l = m }
}
}
}
}This (italicized part) feels like a weird requirement. Function/API documentation (which this author's code lacks) is important, and I don't think I ever write a call to an unfamiliar function without checking the docs for it. Assuming that you know what a function does based on the name alone is a recipe for buggy code.
This is also a kind of unusual formulation of binary search which searches across a monotonic function space rather than a sequence. I get that this is more general and can be used to implement the latter, but a less general interface that only works on sequences is IMO more intuitive: in that case, I expect it to return either the index of the target number, or the index where it should be inserted if not found. It's somewhat telling that the author doesn't offer any code examples demonstrating how to use this wonderful general function to solve the common use case of finding an element in a sorted vector, or inserting a new one into the correct location.
Cannot tell if this is a joke.
The code itself looks very straightforward, two preconditions and a loop. I don't see any significant difference in terms of complexity compared to any other low-level systems programming language. It's obviously a bit more verbose than python or haskell, but those are not targeting the same audience.
For a practical programmer, bsearch is on a sorted array. Implement that, with the necessary type abstractions. So: build the objects into an array of references, sort that, then bsearch it already. If you need to keep that array around, do that. Way easier to look at the code and be reasonably confident that it's ok.
Anything else is fobbing off the abstractions to something not-nearly-obvious ( eg, a 'Mid' trait?). I disagree that this is a defect in Rust.
But I suppose that enabling this kind of misabstraction may indeed be a defect in Rust.
Sound an awful lot like Fermat: "the truly marvellous proof which the margin is too narrow to contain".
Sometimes returning an error and sometimes returning an incorrect value doesn't sound like a binary search that is "absolutely reliable".
Also, s/consolation/consultation/
if theres a risk that your input is unstable in ordering, then reaching for bsearch seems like a "premature optimization", and youd be better off with a straightforward linear scan until requirements (or your kanguage of choice's bst/heap/rbt equivalent), or data is preprocessed upstream in a way that makes sense for the contexts wanting to use it.
1. It doesn't support the empty array, because l and r are both inclusive.
2. It performs way more predicate tests than necessary.
3. An incorrectly implemented Mid trait causes a silent infinite loop.
4. If a fundamental programming error in the sanity check is detected it just silently returns Nones instead of panicking.
There's a part where it sorts `self` and `other`, then subtracts.
let difference = *large - *small;
Suppose large is 0 and small is i32::MIN. Kaboom, right?For reference: https://github.com/dfinity/ic/commit/79aca8ede9fccd322e0e011...
What I don't quite understand is why the opposite (`-20.mid(&0b10u64)`) still errors out with the same exact "unsigned values cannot be negated" error. Why is the unsigned and not the -20 taking prececence here? Doing `-20i64.mid(&0b10u64)` I get the expected error: "expected `&i64`, found `&u64`."
C ... for ... sane tings?
Empty range?
enum Found { Empty, Before(T), After(T), Between(T, T), }
... and a range parameter instead of L and R.
And, TBH, this should probably just panic! on an invalid predicate rather than returning an error. Programming errors should panic.
Is it even possible for a rust function to take a mutable borrow as a parameter, decay it to an immutable borrow and store that somewhere, such that other people can borrow the parameter after the function has returned?
I think it's possible under some scenarios with the appropriate lifetimes? For example, https://rust.godbolt.org/z/6fe8c4sf4:
struct Magic<'a> {
reference: &'a i32
}
impl<'a> Magic<'a> {
fn new<'b: 'a>(source: &'b mut i32) -> Magic<'a> {
Magic { reference: source }
}
}
fn double(i: &i32) -> i32 {
i * 2
}
fn main() {
let mut i = 0;
let m = Magic::new(&mut i);
double(m.reference);
}
I think this will stop working once the compiler can no longer see that the lifetimes work out, but I think that makes sense - there's a mutable object underlying the mutable borrow, and the compiler needs to verify that no one else can get another mutable borrow while the derived immutable borrows are live. struct Sorted<'a> {
reference: &'a [i32]
}
impl<'a> Sorted<'a> {
fn in_place<'b: 'a>(source: &'b mut [i32]) -> Sorted<'a> {
source.sort();
Sorted { reference: source }
}
}
fn use_sorted_array(sorted_arr: &Sorted) {
let arr = sorted_arr.reference;
println!("{} {} {}", arr[0], arr[1], arr[2]);
}
fn main() {
let mut arr = [2, 3, 1];
let sorted_arr = Sorted::in_place(&mut arr);
use_sorted_array(&sorted_arr);
}
You might also want to add methods on Sorted for creating a Option<Sorted> by verifying the sortedness of an immutable reference and a method that copies an array and sorts it (it would need to return a struct of a Box for managing the lifetime and a Sorted pointing into the box, or something I guess, probably need to use Pin?)---
On a completely separate note, this will actually not work for the binary search in the article in question, as it uses binary search on a predicate. Having a sorted array doesn't guarantee that the predicate will be monotonic.
```
pub trait Program<T> {
pub fn run(self);
}pub fn run_program<T: Program>(program: T) {
program.run();
}```
Boom. Just look at the code. How could it be simpler? This works for any struct that implements the `Program` trait.
/s
While the code in the original post is elegant, and cool from a "hey look what i can do!" perspective, I really feel like at some point we lost the plot on templates with Rust. When you try to finagle an implementation of a general algorithm, you just end up with a solution that works poorly in all cases, instead of one that works well in most cases.
I've written a lot of Rust, and I definitely know what it's like to get high off of your own template fumes. I once wrote a function with five generics and two trait constraints and I thought it was the coolest shit I'd ever done. But really, I could have just written, like, three discrete functions, and it would have been clearer and smaller.
If someone used something with binary search algorithm other than the Mid implementation shown in the post, that would categorically be bad code from someone who was trying to be too clever. If someone has a unique binary search to do, they should write it themselves. That way, the whole algorithm will be on display, and it will be easier to see ways to make it simpler or improve it in the context of the unique search operation. Also, it will be much clearer.