DEV Community

Alexey Maltsev
Alexey Maltsev

Posted on • Originally published at maltsev.space

Benefits of deconstuctors for custom types

Alt Text

About tuples in C# 7

New tuples and its support in C# 7 are blazing-cool:

  • they are structures, so no heap allocations;
  • they have aliases for names of its fields (tuple.Info vs old tuple.Item1);
  • they bring syntax-sugar of deconstruction;

Moreover, the deconstruction can be used not only for build-in tuples, but also for your own classes and structures.

Deconstruction for tuples

Let's start with the original usage for tuples. If you are already familiar with it, you may jump to the next block.

For example, you have a method, which returns a statistics about most frequent word in a form of a tuple with two fields: word and count. Below is the example of such method:

static (string, int) GetMostFrequentWord(string text)
{
    var group = text.Split(' ')
        .GroupBy(s => s)
        .OrderByDescending(grouping => grouping.Count())
        .First();

    return (group.Key, group.Count());
}
Enter fullscreen mode Exit fullscreen mode

As you see, GetMostFrequentWord returns unnamed tuple, and you can access its fields via Item1 and Item2:

static void Main(string[] args)
{
    string text = "tuple for testing tuple";
    var stat = GetMostFrequentWord(text);
    Trace.WriteLine($"word: {stat.Item1}, count: {stat.Item2}");
}
Enter fullscreen mode Exit fullscreen mode

On the other hand, if you implement named-tuple, those values can be accessed through aliases, you just need to make some changes into declaration of the tuple:

// we added aliases for the output below
static (string word, int count) GetMostFrequentWord(string text)
{
    // same logic
}

static void Main(string[] args)
{
    string text = "tuple for testing tuple";
    var stat = GetMostFrequentWord(text);
    // but now we use aliases for fields
    Trace.WriteLine($"word: {stat.word}, count: {stat.count}");
}
Enter fullscreen mode Exit fullscreen mode

However, our original need is just getting those fields word and count, we don't really care about grouping tuple. So be it: with the help of deconstruction, we can initialize only variables word and count:

static void Main(string[] args)
{
    string text = "tuple for testing tuple";
    // no tuple now, just values from its fields
    (string word, int count) = GetMostFrequentWord(text);
    Trace.WriteLine($"word: {word}, count: {count}");
}
Enter fullscreen mode Exit fullscreen mode

In addition, you can even use var for completely deconstructed tuple, instead of specifying explicit types for every field:

var (word, count) = GetMostFrequentWord(text);
Enter fullscreen mode Exit fullscreen mode

If you don't need some fields from deconstruction, you can use another feature of C# 7 - discards. For example, if we want to discard the creation of variable count during deconstruction, we can pass _ instead:

static void Main(string[] args)
{
    string text = "tuple for testing tuple";
    var (word, _) = GetMostFrequentWord(text);
    Trace.WriteLine($"Most frequent word: {word}");
}
Enter fullscreen mode Exit fullscreen mode

Implementing deconstruction for custom types

Using deconstruction of tuples is quite straightforward, how about user-defined types? You may want GetMostFrequentWord to return your own struct WordStat:

public struct WordStat
{
    public string Word { get; set; }

    public int Count { get; set; }

    public WordStat(string word, int count)
    {
        Word = word;
        Count = count;
    }
}

static WordStat GetMostFrequentWordStats(string text)
{
    var group = text.Split(' ')
        .GroupBy(s => s)
        .OrderByDescending(grouping => grouping.Count())
        .First();

    return new WordStat(group.Key, group.Count());
}
Enter fullscreen mode Exit fullscreen mode

Generic tuples are great, but there are several reasons for using your own models:

  • Better readability, as your code became less "technical".
  • Easy refactoring, for example, if you want to change field names or add new ones.
  • Your model has domain specifics, but generic tuples - doesn't.

So, we will use WordStat instead of a tuple, but can we use deconstruction for our model?

We are lucky, because we can add this feature to our type. All is needed is adding new public method Deconstruct with out parameters, that will be extracted during deconstruction:

public void Deconstruct(out string word, out int count)
{
    word = Word;
    count = Count;
}
Enter fullscreen mode Exit fullscreen mode

After that, smart compiler will use this method to produce deconstructed values:

static void Main(string[] args)
{
    string text = "tuple for testing tuple";
    // visually nothing has changed
    var (word, count) = GetMostFrequentWordStats(text);
    Trace.WriteLine($"word: {word}, count: {count}");
}
Enter fullscreen mode Exit fullscreen mode

Deconstructed fields must be of same types and in same order and count, as they appear in Deconstuct. You can have as much configurations of deconstruction, as how many overrides of Deconstruct you have.
One more thing - Deconstruct may be an extension-method!

Let's add a new field WordLength to WordStat and write an extension to get all those three fields:

public static class Extensions
{
    public static void Deconstruct(this Program.WordStat stat, out string word, out int count, out int length)
    {
        word = stat.Word;
        count = stat.Count;
        length = stat.WordLength;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can get word's length from deconstruction:

static void Main(string[] args)
{
    string text = "tuple for testing tuple";
    var (word, count, length) = GetMostFrequentWordStats(text);
    Trace.WriteLine($"word: {word}, count: {count}, length: {length}");
}
Enter fullscreen mode Exit fullscreen mode

Use-cases of deconstruction

Imagine a service, which sends you some data, for example aggregational count of some records in some data-sources. Method takes a long time to aggregate. Let's say it's signature will be:

Task<int> Count(string[] dataSourcesUrls);
Enter fullscreen mode Exit fullscreen mode

What if service failed, while retrieving count; or even more complicated - it failed only for several sources. What will be the result of Count: partial count, default(int), -1, null (if it will be Nullable<int>)? Or maybe Count will throw an exception?

One approach is to use complex type as a result; often it is named as Operation Result. It usually consists of requested data and some information about errors, which have been occurred (or not). Below is the example:

// Our service
public class AggregationService
{
    public Task<OperationResult<int>> Count(string[] dataSourcesUrls)
    {
        return Task.FromResult(
            OperationResult<int>.CreatePartly(100,
                new Exception($"Data from url '{dataSourcesUrls[0]}' was not loaded, but it's OK, go on")));
    }
}
Enter fullscreen mode Exit fullscreen mode
// Complex result
public class OperationResult<TResult>
{
    private OperationResult()
    {
    }

    public bool Success { get; private set; }
    public TResult Result { get; private set; }
    public Exception Exception { get; private set; }
    public bool IsTotallySuccessful => Success && Exception == null;

    // Factories
    public static OperationResult<TResult> CreateSuccessful(TResult result)
    {
        return new OperationResult<TResult>
        {
            Success = true,
            Result = result
        };
    }

    public static OperationResult<TResult> CreatePartly(TResult result, Exception e)
    {
        return new OperationResult<TResult>
        {
            Success = true,
            Result = result,
            Exception = e
        };
    }

    public static OperationResult<TResult> CreateFailed(TResult result, Exception e)
    {
        return new OperationResult<TResult>
        {
            Success = false,
            Exception = e
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
// Consumer of our service
public class Consumer
{
    public async Task Run()
    {
        var urls = new[]
        {
            "https://www.maltsev.space/sources/1",
            "https://www.maltsev.space/sources/2",
            "https://www.maltsev.space/sources/3"
        };

        var service = new AggregationService();

        var countResult = await service.Count(urls).ConfigureAwait(false);
        if(countResult.IsTotallySuccessful)
            Trace.WriteLine($"Total count: {countResult.Result}");
        else if (countResult.Success)
            Trace.WriteLine($"Count is around: {countResult.Result}\nError: {countResult.Exception}");
        else
            Trace.WriteLine($"Error: {countResult.Exception}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Ths main idea is that it is grouping result and errors, so we have full information about result of the operation and may react as we want. In our case we want to show user any result, even if it isn't full.

So, as in the example with frequent word, our model of complex result just group everything together. It's useful when we construct complex result via our factory-methods, but then in the consumer we need only its fields.

There may be different ways of how we consume these results, for example, we just need requested data, even if it may be equals default (empty).

So, we can use deconstruction and discards. Firstly, we create Deconstruct with all 4 fields:

public void Deconstruct(out TResult result, out bool success, out bool totalSuccess, out Exception exception)
{
    result = Result;
    success = Success;
    totalSuccess = IsTotallySuccessful;
    exception = Exception;
}
Enter fullscreen mode Exit fullscreen mode

Then we can deconstruct it as we want:

var (count, _, totalSuccess, _) = await service.Count(urls).ConfigureAwait(false);

if(totalSuccess)
    Trace.WriteLine($"Total count: {count}");    
Enter fullscreen mode Exit fullscreen mode

And thats it, were are ready to go on!

Further reading

Top comments (0)