Skip to content

C++ Interop: How should C++ operators interact with Carbon operator interfaces? #6166

@bricknerb

Description

@bricknerb

Summary of issue:

This question seeks to define the model for integrating C++ operator overloads into Carbon. The central challenge is to create a predictable system that reconciles two different paradigms: C++'s complex, context-sensitive overload resolution (including ADL and implicit conversions) and Carbon's interface-based impl lookup. The decision required is to specify how these two mechanisms should interact. Should Carbon's impl lookup delegate to C++'s lookup, should impls be generated statically when a type is imported, or should they be synthesized on-demand? Furthermore, how should this system prioritize between an imported C++ operator and a user-defined Carbon impl for the same C++ type? The goal is a design that makes using C++ operators in Carbon feel seamless and intuitive.

Details:

Problem Statement

When a Carbon user imports a C++ type, they should be able to use its overloaded operators naturally. For example, if a user-defined C++ class MyProject::Widget with an overloaded operator== is imported, a Carbon developer should be able to write widget1 == widget2 and have it work as expected.

This requires bridging two different systems:

  • C++: Uses operator overloading with a complex lookup mechanism that includes Argument-Dependent Lookup (ADL).
  • Carbon: Uses dedicated operator interfaces (e.g., EqWith) and selects an implementation via impl lookup.

The trigger for this special C++ interop logic is any operation involving a Cpp associated type. This is any type whose definition involves an imported C++ type, such as:

  • The C++ type itself: Cpp.Widget
  • A generic Carbon type with a C++ type parameter: MyVector(Cpp.Widget)
  • A Carbon struct with a C++ member: MyStruct { x: Cpp.Widget }
  • A pointer to a C++ type: Cpp.Widget*

We need to define a clear and predictable model for how a C++ operator overload is selected and used when a Carbon operator is invoked on C++ types.

Goals and Key Considerations

Any proposed solution must account for the following:

  • Triggering: When should we perform C++ operator lookup versus Carbon impl lookup?
  • Type Handling: How should the system handle a mix of C++ types, Carbon types, and primitive types as operands or return values?
  • User Extensibility: Users should be able to write their own explicit impl for a Carbon operator interface involving C++ types, and the selection between the user's impl and an imported C++ operator should be predictable.
  • Ownership: The solution must respect both Carbon's orphan rule (impl must be in the same library as the interface or the type) and C++'s module ownership rules.
  • Consistency: The proposed model should be consistent with how other C++ features are handled, such as destruction (Destroy), conversions (As), and copy/move operations.

Proposed Alternatives

1. Blanket impl

A single, generic impl would be defined next to each operator interface (e.g., impl Cpp.T as AddWith(...)). This impl would be responsible for triggering C++ lookup and overload resolution at compile time.

  • Pros: Conceptually simple; keeps the logic centralized with the Carbon interface. Destroy is planned to use this approach.
  • Cons: May be difficult to reconcile with C++'s ADL rules. It creates a potential conflict if a user wants to provide a more specific impl for a C++ type.
2. Generate impl on Type Import

When a C++ type is imported, we would automatically generate a corresponding Carbon impl for each C++ operator it supports within the Cpp namespace.

  • Pros: The impl is tightly coupled to the imported C++ type.
  • Cons:
    • Impractical to discover all operators: C++ operator lookup is context-dependent (relying on ADL), so it's often impossible to find all relevant operators when a type is first imported without knowing how it will be used.
    • Inefficient eager generation: This approach generates code for all of a type's operators upfront, even those that are never used, leading to slower compilation and code bloat.
    • Ownership challenges: It's unclear which library "owns" the generated impl, potentially leading to duplicate definitions.
3. Hook impl Lookup and Synthesize a Witness

Instead of generating a static impl, we would hook into Carbon's impl lookup process. If the types involve Cpp, we would perform C++ operator lookup and, if successful, dynamically synthesize a "witness" (a proof of the impl) without creating an actual impl declaration.

  • Pros: Highly dynamic and avoids static impl generation. It's as if a non-generic impl exists for every valid C++ operator call.
  • Cons: The behavior could feel "magic" and might be difficult to debug. It's unclear how this would interact with explicit user-defined impls, which might be preferred over the C++ operator.
4. Hook impl Lookup, Synthesize impl, and use Carbon Selection

This is a hybrid "just-in-time" approach that leverages the strengths of both systems.

  1. When an operator is used on a Cpp associated type, trigger C++ operator lookup and overload resolution.
  2. If the result is ambiguous, raise a compile-time error.
  3. If a single best function is found, convert its C++ signature into a temporary, specific Carbon impl. For example, C++'s auto operator+(A<int>, B<int>) -> C<int>; becomes an impl for Cpp.A(i32) as AddWith(Cpp.B(i32)) -> Cpp.C(i32).
  4. This synthesized impl is then added to the set of candidates for regular Carbon impl selection.
  • Pros:

    • Robust and Predictable: It uses C++'s powerful overload resolution to find the right candidate but integrates it into Carbon's impl selection process. This allows user-defined impls to compete with (and potentially override) C++ operators in a predictable, efficient way.

    • Handles Complex C++ Rules (like Implicit Conversions): This model correctly leverages C++'s own rules without re-implementing them. For example, consider a C++ operator+(A, B) and an implicit conversion from A to B. When a Carbon user writes a + a (where a is Cpp.A), C++ overload resolution will find operator+(A, B) as a valid candidate by applying the conversion. This approach then synthesizes a concrete impl for Cpp.A as AddWith(Cpp.A) for Carbon's impl selection.

      This reflects a key insight from the design discussion: while the synthesized impl is specific to the call, the underlying C++ lookup is powerful enough to behave as if a more generic rule existed. In more formal terms, it provides the power of an impl forall [T:! type] Cpp.A as AddWith(T), where C++'s own conversion rules determine the valid types for T.

  • Cons: This is the most complex alternative to implement.

Recommended Approach

The discussion so far favors Alternative 4. It correctly delegates the initial lookup to C++'s established rules while allowing the final decision to be made by Carbon's impl selection mechanism. This provides a predictable, "pay-for-what-you-use" model that correctly handles complex C++ features like implicit conversions and allows users to supplement C++'s behavior with their own Carbon impls.

This issue seeks a decision on whether to proceed with this design.

Any other information that you want to share?

Metadata

Metadata

Assignees

No one assigned

    Labels

    leads questionA question for the leads team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions