Skip to content

.NET Projects Tutorial

Lambert Clara edited this page Mar 10, 2021 · 2 revisions

.NET Projects Tutorial

Sharpmake has first-class support for .NET. This tutorial will run you through the process of creating a solution that contains a C# project, a C++/CLI interop library, and a native C++ library.

Most .NET programmers will care only about C# and Visual Basic .NET, but C++/CLI is a fully-featured .NET language that is very useful for creating .NET wrappers for native C++ code and this is a use case that Sharpmake provides support for, so it is included as part of the .NET tutorial.

The tutorial is going to target 2 versions of the .NET framework to show a limitation that comes when generating a project targeting multiple versions of the .NET Framework.

The Sample Code

The code we are going to work with is a very simple dice roll app, but we put the random number generation in native C++ to demonstrate C++/CLI project generation.

This is the C# code. Put it in a csharp folder.

using System;
using System.Linq;
using LibRng;

namespace DiceRoller
{
    public class DiceRollerApp
    {
        public const int RollCount = 10000000;

        public int Run()
        {
            try
            {
                var rng = new Rng(1, 6);
                var results = new long[6];

                for (int i = 0; i < RollCount; i++)
                    results[rng.Roll() - 1]++;

                for (int i = 0; i < 6; i++)
                    Console.WriteLine($"N({i + 1}) = {results[i]}");

                double expectedMean = RollCount / 6.0;
                double squaredVariance = results.Sum(result => Math.Pow(expectedMean - result, 2));
                double variance = Math.Sqrt(squaredVariance);
                Console.WriteLine($"RNG Variance: {variance:0.0000} ({100 * variance / RollCount:0.0000}%)");

                Console.WriteLine("Press any key to exit...");
                Console.ReadKey(true);

                return 0;
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
                return -1;
            }
        }

        public static int Main()
        {
            var app = new DiceRollerApp();
            return app.Run();
        }
    }
}

This is the C++ code that contains the random number generation functions. Put it in a cpp folder.

// librng.hpp
#if !defined(_LIBRNG_HPP)
#define _LIBRNG_HPP

#include <random>

class Rng
{
public:
    Rng(int min_range, int max_range);

public:
    int Roll() noexcept;

public:
    inline int GetMinRange() const noexcept { return _random_range.min(); }
    inline int GetMaxRange() const noexcept { return _random_range.max(); }

private:
    std::default_random_engine _random_engine;
    const std::uniform_int_distribution<int> _random_range;
};

#endif // _LIBRNG_HPP
// librng.cpp
#include "librng.hpp"

#include <exception>
#include <random>
#include <stdexcept>

Rng::Rng(int min_range, int max_range)
    : _random_range(min_range, max_range)
{
    if (min_range >= max_range)
        throw std::range_error("min_range must be smaller than max_range.");
}

int Rng::Roll() noexcept
{
    return _random_range(_random_engine);
}

Finally, here's the code for the C++/CLI interop library that wraps the native library so that the C# code can use it. Put it in a cppcli folder.

// Rng.h
#pragma once
#pragma managed(push, on)

class Rng;
using NativeRng = ::Rng;

namespace LibRng
{
    public ref class Rng
    {
    public:
        property int MinRange { int get(); }
        property int MaxRange { int get(); }

    public:
        Rng(int minRange, int maxRange);
        ~Rng();
        !Rng();

    public:
        int Roll();

    private:
        NativeRng* _rng = nullptr;
    };
}

#pragma managed(pop)
// Rng.cpp
#pragma managed
#include "Rng.h"

#pragma managed(push, on)
#   include <string>
#   include <stdexcept>
#   include <msclr/marshal.h>
#pragma managed(pop)

// Those break the librng and are defined in msclr/marshal somehow.
#undef min
#undef max

#include <librng.hpp>

using namespace System;
using namespace LibRng;

using ManagedRng = LibRng::Rng;

int ManagedRng::MinRange::get()
{
    if (!_rng)
        throw gcnew ObjectDisposedException("Rng");

    return _rng->GetMinRange();
}

int ManagedRng::MaxRange::get()
{
    if (!_rng)
        throw gcnew ObjectDisposedException("Rng");

    return _rng->GetMaxRange();
}

ManagedRng::Rng(int minRange, int maxRange)
{
    try
    {
        _rng = new NativeRng(minRange, maxRange);
    }
    catch (std::range_error& ex)
    {
        String^ message = msclr::interop::marshal_as<String^>(ex.what());
        throw gcnew ArgumentException(message);
    }
}

ManagedRng::~Rng()
{
    this->!Rng();
}

ManagedRng::!Rng()
{
    delete _rng;
    _rng = nullptr;
}

int ManagedRng::Roll()
{
    if (!_rng)
        throw gcnew ObjectDisposedException("Rng");

    return _rng->Roll();
}

The Script

The Target Definition

Targets work the same way they used to, but we're now introducing the DotNetFramework fragment which allows us to specify the version of the .NET Framework we want to generate solutions and projects for.

using Sharpmake;

public static class Common
{
    public static ITarget[] GetTargets()
    {
        return new[]
        {
            new Target(

                // Building for amd64 and x86. Note that Any CPU is also an
                // option here, but we can't use that with C++ projects.
                Platform.win64 | Platform.win32,

                DevEnv.vs2015,
                Optimization.Debug | Optimization.Release,

                // When building for the .NET framework, you can specify a .NET
                // Framework to target. As always, you can use the bit OR
                // operator to specify many at once.
                framework: DotNetFramework.v4_5 | DotNetFramework.v4_6

            )
        };
    }
}

public static class Main
{
    [Sharpmake.Main]
    public static void SharpmakeMain(Sharpmake.Arguments arguments)
    {
        arguments.Generate<DotNetSolution>();
    }
}

Platform

On top of x86 and x64, the .NET Framework also has IA_64 and Any CPU. While the former has long been deprecated in .NET and is not supported at all by Sharpmake, it is possible to target Any CPU very easily by specifying the Platform.anycpu fragment in the target. This fragment is only available for .NET projects.

Unfortunately, we cannot demo Any CPU in this tutorial because it is not compatible with C++/CLI and native C++ projects. (Native code is not compiled in IL.) If you have a pure C# solution though, you can (and probably should) target that platform.

The C# Project

We will start by writing the project for the C# executable. It is a simple application that runs in the Windows terminal.

// The C# project. C# projects *must* inherit CSharpProject (instead of
// Project) and specify a .NET output type during configuration, but otherwise
// it works the same as any C++ project.
[Generate]
public class CSharpExecutable : CSharpProject
{
    public CSharpExecutable()
    {
        Name = "DiceRoller";
        SourceRootPath = @"[project.SharpmakeCsPath]/csharp";
        AddTargets(Common.GetTargets());
    }

    [Configure]
    public void Configure(Configuration conf, Target target)
    {
        // As for the C++/CLI project, we have specific output types for .NET.
        // Here we want a console application.
        conf.Output = Configuration.OutputType.DotNetConsoleApp;

        // Different versions of the .NET Framework generate different
        // projects. They must also generate to different paths to avoid
        // conflicts when compiling multiple configurations.
        conf.ProjectFileName = @"[project.Name]_[target.Framework]";
        conf.ProjectPath = @"[project.SharpmakeCsPath]/generated/csharp/[target.Framework]";

        // We don't care whether a dependency is a .NET reference or a native
        // C++ project. Sharpmake's dependency system works for all kinds of
        // project, regardless of whether they generate .NET references or
        // native code binaries.
        conf.AddPrivateDependency<InteropLibrary>(target);
    }
}

C# projects are created by deriving from the CSharpProject class instead of Project, but since CSharpProject derives from Project, you still have access to everything you are used to with C++ projects.

Since C# project have different output types than native C++ projects, so the Configuration::OutputType enumeration you use is also different. In .NET, a project can be either one of those:

  • A class library. (.DLL)
  • A console application. (.EXE)
  • A windows application. (.EXE)

The difference between the last 2 is that a console application automatically opens the Windows terminal while the other does not.

Project Sub-types

Visual Studio projects can also specify a sub-type that tells Visual Studio how to treat it and what icon to display. Subtypes include Windows Forms projects, WPF projects, MSTest projects, VSIX projects, and so on. You can assign this sub-type in Sharpmake by setting the ProjectTypeGuids field of a Project, for example ProjectTypeGuids = CSharpProjectType.Wpf.

A very important thing to note is that, in Visual Studio, a configuration cannot specify a version of the .NET Framework. If you try to generate configurations that target different frameworks in a single solution, it will not work correctly in Visual Studio. Sharpmake configurations, however, can contain different versions of the .NET Framework.

To work around that limitation in Visual Studio, we need to generate a unique solution with their own sets of projects for each version of the .NET Framework.

Note that a C# project can include a dependency to a C++ project. Sharpmake will automatically figure out that, since it is a C# project, the dependency needs to be added as a reference instead of as a static library and a set of headers.

Common Library Base Class

Next, we write a base class to serve for both C++ libraries.

public abstract class BaseCppLibrary : Project
{
    public BaseCppLibrary()
    {
        AddTargets(Common.GetTargets());
    }

    [Configure]
    public virtual void Configure(Configuration conf, Target target)
    {
        // Generate a different project depending on the .NET Framework,
        // because a configuration in Visual Studio cannot target a specific
        // version of the .NET Framework.
        conf.ProjectFileName = @"[project.Name]_[target.Framework]";

        // C++/CLI requires SEH exceptions. (Likely because that is the
        // exception framework that the CLR actually uses internally.) There is
        // no need to set the Common Language Runtime compiler option to
        // compile for .NET unless you need to make a pure, safe, etc.
        // assembly.
        conf.Options.Add(Options.Vc.Compiler.Exceptions.EnableWithSEH);

        // C++/CLI does not support non-DLL runtime library so we need to
        // change this option.
        if (target.Optimization == Optimization.Debug)
            conf.Options.Add(Options.Vc.Compiler.RuntimeLibrary.MultiThreadedDebugDLL);
        else
            conf.Options.Add(Options.Vc.Compiler.RuntimeLibrary.MultiThreadedDLL);
    }
}

Another thing to note is that, due to limitations imposed by the C++/CLI language and the CLR, we need to use a runtime library based on a DLL, as well as SEH C++ exceptions.

The Native C++ Project

Next is the project for the native (not .NET) C++ project.

// The native C++ library. Nothing out of the ordinary about it.
[Generate]
public class NativeLibrary : BaseCppLibrary
{
    public NativeLibrary()
    {
        Name = "librng";
        SourceRootPath = @"[project.SharpmakeCsPath]/cpp";
    }

    public override void Configure(Configuration conf, Target target)
    {
        base.Configure(conf, target);
        conf.ProjectPath = @"[project.SharpmakeCsPath]/generated/cpp/[target.Framework]";

        // Optional: create a static link library that can be completely
        // embedded into the mixed mode interop DLL so we don't need to carry
        // both a native and a managed DLL with the C# project.
        conf.Output = Configuration.OutputType.Lib;

        // The C++/CLI project depends on this one so it needs the includes.
        conf.IncludePaths.Add(@"[project.SourceRootPath]");
    }
}

There should not be anything new here, otherwise please read the basic tutorials.

The C++/CLI Interop Project

The last project is the one that contains the C++/CLI wrappers for .NET

// The interop library in C++/CLI. It will glue .NET and C++ together.
[Generate]
public class InteropLibrary : BaseCppLibrary
{
    public InteropLibrary()
    {
        Name = "LibRng.Interop";
        SourceRootPath = @"[project.SharpmakeCsPath]/cppcli";
    }

    [Configure]
    public override void Configure(Configuration conf, Target target)
    {
        base.Configure(conf, target);

        // A C++/CLI library works the same as a normal C++ library, except
        // that its output type is one of the DotNet option.
        conf.Output = Configuration.OutputType.DotNetClassLibrary;

        conf.ProjectPath = @"[project.SharpmakeCsPath]/generated/cppcli/[target.Framework]";
        conf.AddPrivateDependency<NativeLibrary>(target);
    }
}

A C++/CLI project is created just like any other C++ project, except that you you use an output type that you would use for a C# project, ie: OutputType.DotNetClassLibrary.

Steps for C++/CLI Projects

C++/CLI projects have more caveats because it's basically a normal C++ project with a particular configuration instead of being it's own thing like a C# project. To recap, in order to make a C++/CLI project, you need to:

  1. Use a target that specifies the .NET Framework version you want.
  2. Set exception mode to SEH, if using exceptions.
  3. Use a C++ runtime that is based on a DLL, not a static library.
  4. Set the output type to a .NET output instead of a exe, lib, etc.

The Solution

Finally, we write the solution that will contain all 3 libraries.

[Generate]
public class DotNetSolution : Solution
{
    public DotNetSolution()
    {
        Name = "DotNet";
        AddTargets(Common.GetTargets());
    }

    [Configure]
    public void Configure(Configuration conf, Target target)
    {
        conf.SolutionFileName = @"[solution.Name]_[target.Framework]";
        conf.SolutionPath = @"[solution.SharpmakeCsPath]/generated";
        conf.AddProject<CsharpExecutable>(target);
        conf.AddProject<NativeLibrary>(target);
        conf.AddProject<InteropLibrary>(target);
    }
}

At this point, solution generation should not surprise you. The only thing new here is that we need to specify a custom name for the solution, just like we did for the projects.

Run Sharpmake

Done! Save your files and run Sharpmake. Because of the .NET Framework limitation workaround, it should generate 2 solutions and 2 project files for each actual project you defined in Sharpmake.