Is there a way to detect at compile time that I'm not filling needed fields of these structs?

I'm using them because a Customer can have a Company and viceversa (in more complex examples).

It may not be practical to have 2¹⁰ actual data types, but what's wrong with having potential 2¹⁰ data types?

I'm actually helping my friend to design something like this (although not realted to databases at all, it's about CPU simulation and optional components that may or may not present) and have come up with the following:

struct Invoice<
    Company: FieldAsRef<String> + FieldAsMut<String> = InaccessibleFiled,
    Customer: FieldAsRef<String> + FieldAsMut<String> = InaccessibleFiled,
> {
    company: Company,
    customer: Customer,
}

…

fn print_invoice<
    Company: FieldAsRef<String> + FieldAsMut<String>,
    Customer: FieldAsRef<String> + FieldAsMut<String>,
>(invoice: Invoice<Company, Customer>) {
    println!("Customer: {}", invoice.customer.field_ref());
    //println!("Company: {}", invoice.company.field_ref());
}

pub fn main() {
    print_invoice(INVOICE
        .with_customer(String::from("Test customer"))
        //.with_company(String::from("Test company"))
    );
}

Here, if you call both with_customer and with_company the code compiles and both invoice.customer and invoice.company can be accessed, but attempt to pass ā€œnot complete enoughā€ invoice would fail at compile time.

Of course this needs some more thinking about design and would probably require some kind of macros (probably proc-macros) to avoid crazy amount of boilerplate, but this may be expanded to something usable, I hope.

3 Likes

@khimru WOW! I'm speechless.

This is the direction I wanted to go when I created this question.

I'm still trying to digest your code though, I'm not very good at Rust.

I noticed that I don't get warnings with cargo-check, but only during compilation:

the evaluated program panicked at 'Field is not accessible'

and if I use this code instead:

fn print_invoice<
    Company: FieldAsRef<String> + FieldAsMut<String>,
    Customer: FieldAsRef<String> + FieldAsMut<String>,
>(
    invoice: Invoice<Company, Customer>,
) {
    // println!("Customer: {}", invoice.customer.field_ref());
    // println!("Company: {}", invoice.company.field_ref());

    let tryme = invoice.company.field_as_ref();

    dbg!(tryme);
}

I only get:

InaccessibleField is accessed

and I don't know which one.

In any case, it would be wonderful if you could point me to some online posts or explain something more yourself and maybe if a macro already exists.

Or if there is a Github project I can subscribe to to stay updated on future developments.

Is it possible that no one has created anything public about this way of writing code?

Thanks again for the great idea!

I'm afraid I haven't seen full-blown macro yet. I have tried to envision how the fully-generic system may work, but wasn't sure what and how I want to provide in it (see at the end).

I have that technique used in a quite a few projects but in very limited fashion, wasn't really thinking about how to make it universal till very recently (when I needed it for fifth or sixth time).

Probably would require more free time than I have, anyway.

I haven't seen anything about that style of [ab]use of Rust typesystem even if I seen vestigial constructs similar to it in many projects.

Looks like something discovered and rediscovered again and again.

Yeah. You win some, you lose some. Because we are sidestepping typesystem it no longer helps us when error happens. That's the limitation which can be [partially] rectify, but yeah, that's inherent in how everything works.

Since we may guarantee that there would be no run-time issues (see below) poor error diagnosys may be acceptable.

Yeah. That's the core idea: hide real information about what your structures could or couldn't actually do from typesystem, but put manual safety guard that detects violations during compilation phase. Of course, because you have removed information about what you structures can or can not do from typesystem cargo check couldn't detect violations — but actual compilation still can.

Except if you would ignore the function that you were supposed to use and would call low-level implementation details instead, as you have found out.

Someone already pointed me to that deficiency before your did, but that can easily be fixed, anyway. Probably worth fixing if you would adapt that technique: error duling linking phase is not nice, sure, but that only happens when you try to use function that you weren't supposed to be used at all, thus it's Ok in my book.

Heck, with link-time exception it may not even be worth having two levels and const-time checking (simplification of the whole thing at the cost of even poorer error messages).

Anyway. The idea here is to provide zero-sized type that pretends that it can be turned into full-blown type (but would panic in initial implementation or fail to link after fixes) and generic function that first verifies during compiltion time that your type doesn't have size of zero and if it's so — then it calls appropriate low-level function.

P.S. Why do I say that it's hard to create universal version? Because every time I have used that technique I needed slightly different properties from it! If you want to have almost-dynamic structure (so customer or company may be both String and i32) then you need a different interface because field_ref/field_mut no longer work. If you want to create assembler with compile-time arguments checking (so add(ah, dil) would be a compile-time error and not runtime error) then you need a way to to complex verification of arguments and so on. All-encompassing thingie becomes more and more complex thus I, eventually, gave up on it. But it may still be feasible, who knows?

1 Like

I've used a similar approach with concrete generics rather than relying on traits which seems more compact and gives better error messages. Are there advantages to the FieldRef/FieldAsRef traits that I'm not seeing?

type Company = String;
type Customer = String;

struct Invoice<Co = (), Cust = ()> {
    company: Co,
    customer: Cust,
}

const INVOICE: Invoice = Invoice {
    company: (),
    customer: ()
};

impl<Co, Cust> Invoice<Co, Cust> {
    fn with_company(self, company: String) -> Invoice<String, Cust> {
        Invoice {
            company,
            customer: self.customer,
        }
    }
    fn with_customer(self, customer: String) -> Invoice<Co, String> {
        Invoice {
            company: self.company,
            customer,
        }
    }
}

impl<Co> Invoice<Co, Customer>    { /* ... methods requiring Customer */ }
impl<Cust> Invoice<Company, Cust> { /* ... methods requiring Company */ }
impl Invoice<Company, Customer>   { /* ... methods requiring both */ }

fn print_invoice<Co>(invoice: Invoice<Co, Customer>) {
    println!("Customer: {}", invoice.customer);
}

fn print_invoice2(invoice: Invoice<Company, Customer>) {
    println!("Customer: {}", invoice.customer);
    println!("Company: {}", invoice.company);
}

pub fn main() {
    print_invoice(INVOICE
        .with_customer(String::from("Test customer"))
    );

    print_invoice2(INVOICE
        .with_customer(String::from("Test customer"))
        .with_company(String::from("Test company"))
    );
}
2 Likes

Yes. In your case print_invoice and print_invoice2 have different signatures.

Worse: if you want to change requirements flexibly your changes would ā€œrippleā€ through layers of helper procedures.

Perhaps IDE can make that process automatic, but I haven't seen one that allows you to do something like this with a few mouse clicks.

And it really need to be ā€œfew mouse clicks and dozens of defines are changedā€ to match flexibility of dynamic languages or C++/Zig: there you just change things in the database layer, then change one procedure where new information is needed and viola: it works.

With your approach if I need to add new field, country, I would need to manually go and change all the declarations of all the functions. And I couldn't even do that with search-replace because they are different! That's not scalable.

1 Like

I see, thanks! My uses haven't been quite as dynamic as the OPs case is likely to be. In my cases fields required for various methods are quite well defined and adding a new generic field isn't likely (one might even say "inconceivable").

... Update: Upon further exploration, the two approaches are very compatible. The structs are the same real type so one could migrate from one style to the other fairly easily (or mix-and-match if you were crazy enough to think that were a good idea). So, specifically, one could start with a simpler concrete generics and, if the code changes too much, migrate to using the Field* traits.

1 Like

I'm speechless, I was looking for something like this too, @duelafn! Thank you very much!

I'm still getting the hang of Rust and this is super helpful! I've never been a super fan of generics, but this use case is amazing!

Like everything there are pros and cons and we need to understand how to make it scalable.

Thanks again!

That's actually how I have used that approach, most of the time. E.g. in x86-64 assembler there are crazy rules that if one of arguments is ah, then you can still use memory access for another argument, but only if you don't use registers r8-r15 for base and index in address.

I have looked on how that's handled in different assemblers and was… not impressed.

dynasm would just happily ignore rules and thus create broken code which differs from what attempted to assemble.

Then iced did a bit better and panicked at runtime. Better, but I wanted compile-time checks.

I have even managed to use macros and create all the ā€œpositiveā€ implementations… but then Rust Analyzer have become very unhappy with me. Well… not Rust Analyzer specifically, but more of people who tried to use it, because when you have almost half-million functions in your crate (even if auto-generated) it takes few hours (sic!) before Rust Analyzer is ready to be used.

At this point going with traits and adding few checks for unsupported combinations of operands is just better for everyone.

And I suspect with ā€œbusiness logicā€ it would be the same: rules like ā€œcross-borderā€ transactions are allowed when either importer have papers A, B, or C or exporter have papers X, Y or Z are pretty common in a business world.

And if you'll try to implement them in types which don't allow arbitrary logic but insist that allowed combos should be:

  1. Additive and
  2. Non-conflicting

… that just doesn't work with mess that is real world!

But there are no need to go for worse error messages and more convoluted approach in cases where additive and non-conflicting implementations are possible!

Thus yes, every time I saw this used that was for a small subset. And as I have said: I keep thinking that maybe full-blown frameworks for that may be developed (similarly to how serde is developed for serialization/deserialization), but I'm not yet sure how that may look like and if it's even needed.

Dang. And I'm 100% sure everyone else (including me) assumed that already knew about that step and wanted something more felxible :see_no_evil:.

Here is an attempt I made about a year ago to share a similar design pattern:
Representing a data structure that has optional components

I really like the concept of generic fields of various functionalities composing together to enable greater functionality.

If you're willing to go to nightly for specialization (to default return Err for non-implemented combinations) you could probably even put this pattern in a Box<dyn Trait>:
Selecting a type with type arguments from numerous combinations at runtime

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.