C++20 Support Comes To C++/CLI

Tanveer Gani

We’re pleased to announce the availability of C++20 support for C++/CLI in Visual Studio 2022 v17.6. This update to MSVC was in response to feedback received from many customers via votes on Developer Community and otherwise. Thank you!

In Visual Studio 2022 v17.6, the use of /clr /std:c++20 will no longer cause the compiler to emit a diagnostic warning that the compiler will implicitly downgrade to /std:c++17. Most C++20 features are supported with the exception of the following:

  • Two-phase name lookup support for managed templates. This is temporarily on hold pending a bugfix.
  • Support for module import under /clr.

Both the above are expected to be fixed in a near-future release of MSVC.

The remainder of this blog post will discuss the background details, limitations, and caveats of C++20 support for C++/CLI.

Brief history and background of C++/CLI

C++/CLI was first introduced as an extension to C++98. It was specified as a superset of C++98 but soon with the introduction of C++11, some incompatibilities appeared between C++/CLI and ISO C++, some of which exist to this day. [Aside: nullptr and enum class were originally C++/CLI inventions that were migrated to ISO C++ and standardized.] With the further introduction of C++14 and C++17 standards, it became increasingly challenging to support the newer language standards and eventually due to the effort required to implement C++20, we decided to temporarily limit standard support to C++17 in /clr mode. A prime reason for this was the pause in the evolution of the C++/CLI specification, which has not been updated since its introduction and therefore could not guide the interaction of C++/CLI features with the new features being introduced in ISO C++.

The design rationale for C++/CLI is spelled out in this document.

Originally envisioned as a first-class language for .NET, C++/CLI’s use has primarily fallen into the category of interop, which includes both directions from managed to native and vice versa. The demise of C++/CLI has been predicted many times, but it continues to thrive in Windows applications primarily because of its strength in interop, where it is very easy to use and hard to beat in performance. The original goals spelled out in the C++/CLI Rationale were:

  1. Enable C++/CLI as a first-class programming language on .NET.
  2. Use the fewest possible extensions. ISO C++ code “just works” and conforming extensions are added to ISO C++ to allow working with .NET types.
  3. Be as orthogonal as possible: if feature X works on ISO C++ types, it should also work on C++/CLI types.

With the specialization of C++/CLI as an interop language, the ECMA specification for it was not updated to keep up with fast evolving .NET features with the result that it can no longer be called a first-class language on .NET. While it can consume most of the fundamental types in the .NET base-class library, not all features are available due to lack of support from C++/CLI. This has resulted in both /clr:safe (allow only the “safe” CLS subset of CLI, disallowing native code) and /clr:pure (compile everything to pure MSIL code) to be deprecated. The only options supported currently are /clr, which targets the .NET Framework, and /clr:netcore which targets NetCore. In both cases, compilation of native types and code, and interop are supported.

Goal (2) was originally achieved nearly perfectly in C++98 but newer versions of ISO C++ caused MSVC to deviate from this goal.

With regard to goal (3), C++/CLI was never specified or implemented to the required level of full generality to satisfy this goal. While some features such as templates were made orthogonal to ISO C++ and managed C++/CLI types, the full generality of features such as allocating managed types on the native heap with new, allowing managed types to embed in native types, etc., were not specified by the ECMA specification. This lack of support turns out to be fortuitous and allows us to move forward with implementing support for newer ISO C++ Standards.

C++20 support for C++/CLI

While C++14 and C++17 were mostly incremental updates to C++11, as far as the core language is concerned, C++20 is a large change because of features like concepts, modules, and coroutines. While coroutines aren’t yet pervasive, concepts and modules are already in use in the ISO C++ Standard Library.

Generally speaking, we need support from the .NET runtime whenever the language introduces a new feature which has a runtime impact in the area of implicit P/Invoke interop. Two examples from C++11 are move constructors and noexcept. The .NET runtime’s P/Invoke engine already knew how to call copy constructors when objects were copied across the managed/native boundary. With the introduction of move constructors, types like std::unique_ptr were handled incorrectly in interop because they have a move constructor instead of a copy constructor. Handling this correctly required adding functionality to the P/Invoke engine on the .NET side and generating the code and metadata to make sure it was called appropriately. For the noexcept case, we still don’t have a correct implementation available. While we handle noexcept correctly in the type system, at runtime an exception crossing a function with a noexcept specification does not result in program termination. Implementing this would, again, require teaching the .NET runtime how to handle such cases but due to no user demand to handle this correctly, it has been left unimplemented.

We wanted to avoid requiring new functionality in the .NET runtime since doing so is time consuming and requires expensive updates, so to add support for C++20 to C++/CLI, we followed this general principle:

Separate C++/CLI types from new C++20 features but allow all possible C++20 features with native types in a /clr compilation.

To achieve this, the implementation of C++20 support in C++/CLI follows this scheme:

  • All native code in a translation unit is compiled to managed MSIL, with the exception of code having these constructs in it:

    1. aligned data types
    2. inline assembly
    3. calls to functions declared __declspec(naked)
    4. references to __ImageBase
    5. functions with vararg (...) arguments
    6. __ptr32 or __ptr64 modifiers on pointer types
    7. CPU intrinsics or other intrinsic functions
    8. virtual call thunks to virtual functions not declared __clrcall
    9. setjmp or longjmp
    10. coroutines

    With the exception of coroutines, the above list has already been the case for compilation to native in prior versions of MSVC. All semantics conform to ISO C++ semantics, as before, the only difference being that the compiler emits managed instructions. Native types are emitted out to metadata as empty value classes with a given size, just to provide tokens for type names when they’re used in function signatures. Otherwise, they remain a black-box to the runtime and are handled entirely at the byte level by the generated managed code.

  • Conformant (two-phase) name lookup ([temp.res] in ISO C++) is enabled in native templates in /clr compilations. Previous versions of MSVC forced the user to specify /Zc:twoPhase- when using /clr and any flag that implied ISO C++ name lookup semantics.
  • Coroutines are implemented by compiling all coroutines to native code. This requires no new support from the .NET runtime and uses the native runtime support. The disadvantage is that all calls to coroutines are interop calls that have the transition and marshalling overhead.
  • Allow concepts to interact only with native types. This is another violation of the “orthogonality” goal mentioned above. The exception is C++/CLI types that have a 1-1 mapping with native types such as System::Int32, etc.
  • Allow import of modules but not export from translation units compiled with /clr. In a similar vein, module header units cannot be generated in a /clr compilation but may be used in one. This restriction is because the module metadata format is based on the IFC specification, which has no support for C++/CLI types.
  • Allow all Standard Library headers to compile with /clr and C++20. Some headers had previously been blocked off from being compiled as managed because they included ConcRT parallel programming headers as a dependency, while some, like <atomic>, had no support in .NET. The dependency on ConcRT is now removed and headers previously forbidden from inclusion with /clr have been updated. Note: some of these headers are still forbidden from inclusion in /clr:pure mode.

No attempt is being made to fix the below pre-existing issues. If there is user demand, these can be handled separately in the future.

  • Lack of noexcept support from .NET, as explained above.
  • enum class has differing meanings in C++/CLI and ISO C++. This is currently resolved by treating such declarations as native enums except when preceded by an access specifier (as C++/CLI allows).
  • nullptr has differing meanings in C++/CLI and ISO C++. In cases where this matters, __nullptr is provided to mean the ISO C++ nullptr value.

Going forward, we plan to use the same strategy to support future ISO C++ versions: compile constructs that have no support from .NET to native code and keep the C++/CLI and ISO C++ type universes separate. In the rare case where a new mechanism is required for marshalling types across managed/native boundaries, we shall require new support from .NET. Historically, this has not happened since C++11.

Examples

The below examples illustrate how C++20 constructs are being handled in C++/CLI.

Coroutines

There are no restrictions on coroutines. They may be used in their full generality with the understanding that the coroutines themselves are always compiled to native code.

Consider the below program fragment:

generator<move_only> answer()
{
    co_yield move_only(1);
    co_yield move_only(2);
    co_yield move_only(3);
    move_only m(4);
    co_return m; // Move constructor should be used here when present
}

int main()
{
    int sum = 0; 
    auto g = answer();
    for (move_only&& m : g)
    {
        sum += m.val;
    }
    return sum == 6 ? 0 : 42+sum;
}

Inspecting the generated IL for this, we can see this IL sequence:

IL_0000:  ldc.i4.0
IL_0001:  stloc.2
IL_0002:  ldc.i4.0
IL_0003:  stloc.0
IL_0004:  ldloca.s   V_8
IL_0006:  call       valuetype 'generator<move_only>'*
              modreq([mscorlib]System.Runtime.CompilerServices.IsUdtReturn)
              modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl)
              answer(valuetype 'generator<move_only>'*)
IL_000b:  pop

together with:

method assembly static pinvokeimpl(/* No map */) 
        valuetype 'generator<move_only>'*
        modreq([mscorlib]System.Runtime.CompilerServices.IsUdtReturn)
        modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl) 
        answer(valuetype 'generator<move_only>'* A_0)
                           native unmanaged preservesig
{
  // Embedded native code
} // end of method 'Global Functions'::answer

showing that answer() is a native method and the call to it in the above MSIL disassembly fragment is an interop call. This is shown only for exposition and the user has to do absolutely nothing to make it work.

Concepts

Since concepts are a mechanism to perform computations on types at compile time, there is no runtime component to be supported by .NET. Further, concepts “disappear” once templates are specialized and templates have been supported for C++/CLI from the outset. There are two kinds of templates supported in C++/CLI, ISO C++ templates and managed templates whose specialization results in managed types. We have made the choice to keep all managed types separate from concepts and this includes managed templates. Any attempt to mix managed types and concepts results in a failed compilation with diagnostics. Note that this excludes types like System::Int32 which can be mapped directly to native types, but boxing of such types is also excluded from interaction with concepts.

#include <concepts>
#include <utility>
template<std::swappable Swappable>
void Swap(Swappable& s1, Swappable& s2)
{
    s1.swap(s2);
}
struct SwapMe
{
    int i;
    void swap(SwapMe& other) { std::swap(this->i, other.i); }
};
value struct SwapMeV
{
    int i;
    void swap(SwapMeV% other) { auto tmp = i; i = other.i; other.i = tmp; }
};
int main()
{
    SwapMe s1, s2;
    Swap(s1, s2);
    SwapMeV s1v, s2v;
    Swap(s1v, s2v);   // error C7694: managed type 'SwapMeV' 
                      // used in a constraint definition or evaluation
                      // or in an entity that uses constraints
    // Boxed value types
    int ^b1 = 1;      
    int ^b2 = 2;
    Swap(b1, b2);     // error C7694: managed type 'System::Int32 ^'
                      // used in a constraint definition or evaluation
                      // or in an entity that uses constraints
}

In the above example, the native types work exactly as for ISO C++ compilation without /clr but attempting to use concepts with C++/CLI types generates a diagnostic. It is possible to widen concepts to allow a carefully chosen subset of the C++/CLI type universe, but for this version of MSVC, we have chosen to keep them separate. Removing the line with the diagnostic and inspecting the disassembly shows us

IL_0009:  ldloca.s   V_3
IL_000b:  ldloca.s   V_2
IL_000d:  call       void
modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl)
  'Swap<struct SwapMe>'(
    valuetype SwapMe* modopt([mscorlib]System.Runtime.CompilerServices.IsImplicitlyDereferenced),
    valuetype SwapMe* modopt([mscorlib]System.Runtime.CompilerServices.IsImplicitlyDereferenced))

Note that the parameter types of the function are SwapMe* and the concept std::swappable does not appear.

Modules

As mentioned above, in C++/CLI modules can only be imported, either as regular ISO C++ modules, or header units, created from headers that contain no C++/CLI code. Thus, we have these restrictions on modules:

module m;               // error under /clr
export module m;        // error under /clr
export class X;
export import m;        // error under /clr
import m;               // OK with /clr
import <vector>;        // header units OK with /clr

From the command-line, certain flag combinations will produce errors:

cl /exportHeader /clr ...       # error
cl /ifcOutput /clr ...          # error
cl /ifcOnly /clr ...            # error

Interop and module import

Since, currently we don’t allow module export under /clr, and since the modules can have code in associated .obj files, unless built with /ifcOnly, it follows that a call to any imported non-inline function has to be an interop call to native code. For templates, this restriction is not required and hence importing, say the class template std::vector, can result in its member functions being compiled to managed code. This is an important consideration for performance since interop calls will inhibit inlining. We shall provide more guidance in a future blog article, when support for modules under /clr ships.

Conclusion and call to action

C++20 support is being added to C++/CLI with very few restrictions as far as native types are concerned. The general principle followed is that of least surprise: if a particular feature is valid in a native compilation, there is a high chance it is also valid under /clr.

If you have C++/CLI code, we encourage you to turn on C++20 compilation, try the product and report bugs via Visual Studio Feedback. If you have specific need for modules to be used in conjunction with C++/CLI, again, we would appreciate feedback.

4 comments

Discussion is closed. Login to edit/delete existing comments.

  • Paulo Pinto 7

    Many thanks for the improvements, going through the C++/CLI route is still much easier than dealing with P/Invoke, so it is good that we can rely on it being around and updated, unlike what happened to C++/CX, forcing us to deal with pre-historic IDL tooling.

    Having C++/CLI kept up to date, allows us to keep having a bit of .NET productivity, while creating bindings for C++ libraries.

  • John Schroedl 5

    It is very heartening to see the effort put into advancing the C++/CLI compiler to support the newer standards. Thanks to the whole team and your management for approving this! We will be using it extensively. The limits you have outlined seem acceptable for my needs though I will happily vote on the issue if others request more support and post the links in these comments.

    Related: building our project with /std:c++20 /clr did reveal an Internal Compiler Error. The compiler isn’t handling codegen for a disposable class member. See https://developercommunity.visualstudio.com/t/Building-clr-code-with-std:c20-in-VS/10343831 if you feel like voting 🙂

  • David Hunter 0

    Does you comment that `import m;` is OK imply that `import std;` will work, at least for the bits in the std module that are c++20 and below not c++23?
    I know the std module is a c++ 23 feature but would that lead it to be treated differently to any other module you want to import?

  • Peter Nimmo 2

    Does this mean that support for Just My Code (/JMC) will be restored?

    As currently when any .cpp file in a C++ project uses the /clr switch then Just My Code functionality is broken and you end up stepping into STL code like std::string or std::vector etc constructors which makes debugging a lot harder than it needs to be.

Feedback usabilla icon