Dennis Tretyakov

C# Primary Constructor and Garbage Collector concern

The primary constructors where introduced in C# 12. I loved the feature, and I embraced using it since the day one, as it makes code shorter. It was quite lame of me, but I lived under impression that class scoped variables passed in a primary constructor are immutable, therefore I them for dependency injection. Finding out that they are not (immutable) was quite a bummer, as injected dependencies should be.

Good or bad this is what it is, and there is still a fair trade of, between shorter code with obvious convention that no one suppose to change dependencies, or a bit more code to enforce immutability.

Actually I hope someday they will take this feature further and will add readonly option like in typescript, for example:

public class MyService(readonly MyDependency myDependency) {}

Anyways, sorry for too long a prelude, but that’s not what the post is about. Mutability was a bummer — that showed me that I started using something without researching for side effects and a price, and I got curious about how does it work with garbage collector.

Long story short — GC works flawlessly. Below I’ll describe my concerns and tests if you are curious as well.

The concern

At which point of time, the class scoped variable will be collected when it’s not needed anymore?

Let’s say, the service class receives some dependency factory in a constructor. It uses the factory only within a constructor to actually create dependencies, but after it’s done, there are no need for factory anymore.

There would be a primitive example:

class ServiceWithRegularConstructor
{
    public readonly Dependency Dependency;

    public ServiceWithRegularConstructor(DependencyFactory dependencyFactory)
    {
        Dependency = dependencyFactory.CreateDependency();
    }
}

In case of regular constructor — there are no question. The class doesn’t have reference DependecyFactory and when there are no any other reference to it — it’s collected.

The primary constructor raises a concern — as class scope variable supposedly available class-wide, will it be collected even if it is not?

Apparently — Yes! The GC is smart enough to detect whether variable was captured by any method within a class or not. More than that — even if it was captured, the reference was changed later, GC will collect unused instance.

Testing method

The testing project available in the github repository, look for Test.cs.

The fake dependency class, and dependency factory. As was mentioned before the dependency factory will be used only within a constructor of a service class and should be collected when not used.

class Dependency {}

class DependencyFactory
{
    public Dependency CreateDependency() => new();
}

I’ll need different classes for test. Therefore there is ServiceFactory that can create certain class instance using given delegate, by passing DependencyFactory to it. It will save WeakReference of initialized DependencyFactory instance, and will use the WeakReference to check if it was or was not collected by GC.

class ServiceFactory
{
    private WeakReference<DependencyFactory>? _dependencyFactoryRef;

    public T CreateService<T>(Func<DependencyFactory, T> fn)
    {
        if (_dependencyFactoryRef != null)
            throw new InvalidOperationException("CreateService cannot be used more than once.");

        var dependencyFactory = new DependencyFactory();
        _dependencyFactoryRef = new WeakReference<DependencyFactory>(dependencyFactory);

        return fn(dependencyFactory);
    }

    public bool DependencyFactoryIsCollected()
    {
        if (_dependencyFactoryRef == null)
            throw new InvalidOperationException("Must use CreateService first.");

        return _dependencyFactoryRef.TryGetTarget(out _) is false;
    }
}

Service with regular constructor

Just to verify that initial assumption is correct — DependencyFactory should be collected after class is constructed.

class ServiceWithRegularConstructor
{
    public readonly Dependency Dependency;

    public ServiceWithRegularConstructor(DependencyFactory dependencyFactory)
    {
        Dependency = dependencyFactory.CreateDependency();
    }
}

Test passes

public class Test
{
    private readonly ServiceFactory _serviceFactory = new();

    [Fact]
    public void RegularConstructor_DependencyFactoryIsCollected()
    {
        var service = _serviceFactory.CreateService(factory => new ServiceWithRegularConstructor(factory));

        GC.Collect();

        Assert.True(_serviceFactory.DependencyFactoryIsCollected());    }
}

Service with primary constructor

Same here, a very basic class, that doesn’t have any method capturing DependencyFactory, therefore the DependeycFactory should be collected right after class instance created.

class ServiceWithPrimaryConstructor(DependencyFactory dependencyFactory)
{
    public readonly Dependency Dependency = dependencyFactory.CreateDependency();
}

Test passes

[Fact]
public void PrimaryConstructor_DependencyFactoryIsCollected()
{
    var service = _serviceFactory.CreateService(factory => new ServiceWithPrimaryConstructor(factory));

    GC.Collect();

    Assert.True(_serviceFactory.DependencyFactoryIsCollected());}

Regular constructor with capture

That might seem a bit of a redundant test, it’s mainly to verify the testing mechanism is working. Class with regular constructor will store DependencyFactory instance in member variable. Therefore instance wont be garbage collected after constructor, however it should be after member variable will be set to null.

class ServiceWithRegularConstructorAndCapture
{
    private DependencyFactory? _dependencyFactory;

    public ServiceWithRegularConstructorAndCapture(DependencyFactory dependencyFactory)
    {
        _dependencyFactory = dependencyFactory;
    }

    public bool HasDependencyFactory() => _dependencyFactory != null;

    public void RemoveReferenceToFactory() => _dependencyFactory = null;
}

Test passes.

[Fact]
public void RegularConstructorWithCaptureAndRelease_DependencyFactoryIsCollected()
{
    var service = _serviceFactory.CreateService(factory => new ServiceWithRegularConstructorAndCapture(factory));

    Assert.True(service.HasDependencyFactory());
    GC.Collect();
    Assert.False(_serviceFactory.DependencyFactoryIsCollected());
    service.RemoveReferenceToFactory();

    Assert.False(service.HasDependencyFactory());
    GC.Collect();
    Assert.True(_serviceFactory.DependencyFactoryIsCollected());}

Primary constructor with capture

That’s a more interesting example of class with primary constructor. Instance of DependecyFactory passed to primary constructor is actually being captured. It’s used in both HasDependencyFactory method and RemoveReferenceToFactory methods. Therefore, the DependencyFactory instance should not be garbage collected right after class construction is completed, however it should be released and collected after RemoveReferenceToFactory method is called.

class ServiceWithRegularConstructorAndCapture
{
    private DependencyFactory? _dependencyFactory;

    public ServiceWithRegularConstructorAndCapture(DependencyFactory dependencyFactory)
    {
        _dependencyFactory = dependencyFactory;
    }

    public bool HasDependencyFactory() => _dependencyFactory != null;

    public void RemoveReferenceToFactory() => _dependencyFactory = null;
}

Test passes.

[Fact]
public void PrimaryConstructorWithCaptureAndRelease_DependencyFactoryIsCollected()
{
    var service = _serviceFactory.CreateService(factory => new ServiceWithPrimaryConstructorAndCapture(factory));

    Assert.True(service.HasDependencyFactory());
    GC.Collect();
    Assert.False(_serviceFactory.DependencyFactoryIsCollected());
    service.RemoveReferenceToFactory();

    Assert.False(service.HasDependencyFactory());
    GC.Collect();
    Assert.True(_serviceFactory.DependencyFactoryIsCollected());}

P.S.

Just in case — I didn’t had an intent to find a bug in garbage collector, but more to clarify the behaviour for myself. It did made things more clear to me, hope it helped you too.
The test also explained me why the are made mutable, even tho’ I still do have a hope for readonly option in future.

© 2020 - 2024, Dennis Tretyakov