Take it to the limit: generic type parameters

Spread the love

What is the highest number of generic type parameters can you have on a single type in C#? I know from experience that the answer is at least 130. (Don’t ask.) But is there an upper bound? According to Stack Overflow, there is no such limit imposed by the spec. But there has to be some limit in practice, right? Let’s find out!

We can start with a .NET Core 2.0 program to generate a class definition with a user-specified number of generic type parameters. Even as verbose as Roslyn is, it is a breeze compared to trying to hand-code this monstrous class!

        private static CompilationUnitSyntax CreateClass(string name, int typeParameterCount)
        {
            TypeParameterSyntax[] typeParams = new TypeParameterSyntax[typeParameterCount];
            ParameterSyntax[] ctorParams = new ParameterSyntax[typeParameterCount];
            StatementSyntax[] ctorStmts = new StatementSyntax[typeParameterCount];
            List<MemberDeclarationSyntax> classMembers = new List<MemberDeclarationSyntax>();
            List<StatementSyntax> stringStmts = new List<StatementSyntax>();
            ArgumentSyntax[] testArgs = new ArgumentSyntax[typeParameterCount];
            TypeSyntax[] testTypeArgs = new TypeSyntax[typeParameterCount];

            PredefinedTypeSyntax stringType = SyntaxFactory.PredefinedType(
                SyntaxFactory.Token(SyntaxKind.StringKeyword));

            const string SBName = "sb";
            TypeSyntax sbType = SyntaxFactory.IdentifierName("StringBuilder");
            EqualsValueClauseSyntax sbInit = SyntaxFactory.EqualsValueClause(
                SyntaxFactory.ObjectCreationExpression(sbType)
                    .WithArgumentList(SyntaxFactory.ArgumentList()));
            stringStmts.Add(SyntaxFactory.LocalDeclarationStatement(
                SyntaxFactory.VariableDeclaration(sbType)
                    .WithVariables(SyntaxFactory.SingletonSeparatedList(
                        SyntaxFactory.VariableDeclarator(SBName)
                            .WithInitializer(sbInit)))));
            MemberAccessExpressionSyntax append = SyntaxFactory.MemberAccessExpression(
                SyntaxKind.SimpleMemberAccessExpression,
                SyntaxFactory.IdentifierName(SBName),
                SyntaxFactory.IdentifierName("Append"));

            for (int i = 0; i < typeParameterCount; ++i)
            {
                string typeName = "T" + i;
                string fieldName = "t" + i;

                typeParams[i] = SyntaxFactory.TypeParameter(typeName);

                IdentifierNameSyntax type = SyntaxFactory.IdentifierName(typeName);
                ctorParams[i] = SyntaxFactory.Parameter(SyntaxFactory.Identifier(fieldName))
                    .WithType(type);

                IdentifierNameSyntax fieldId = SyntaxFactory.IdentifierName(fieldName);
                ExpressionSyntax member = SyntaxFactory.MemberAccessExpression(
                    SyntaxKind.SimpleMemberAccessExpression,
                    SyntaxFactory.ThisExpression(),
                    fieldId);
                ctorStmts[i] = SyntaxFactory.ExpressionStatement(
                    SyntaxFactory.AssignmentExpression(
                        SyntaxKind.SimpleAssignmentExpression,
                        member,
                        fieldId));

                stringStmts.Add(SyntaxFactory.ExpressionStatement(
                    SyntaxFactory.InvocationExpression(
                        append,
                        SyntaxFactory.ArgumentList(
                            SyntaxFactory.SingletonSeparatedList(
                                SyntaxFactory.Argument(member))))));

                classMembers.Add(SyntaxFactory.FieldDeclaration(
                    SyntaxFactory.VariableDeclaration(type)
                        .WithVariables(SyntaxFactory.SingletonSeparatedList(
                            SyntaxFactory.VariableDeclarator(fieldName))))
                    .WithModifiers(SyntaxFactory.TokenList(
                        SyntaxFactory.Token(SyntaxKind.PrivateKeyword),
                        SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword))));

                string arg = string.Format(CultureInfo.InvariantCulture, "{0:x8}", i);
                testArgs[i] = SyntaxFactory.Argument(
                    SyntaxFactory.LiteralExpression(
                        SyntaxKind.StringLiteralExpression,
                        SyntaxFactory.Literal(arg)));

                testTypeArgs[i] = stringType;
            }

            const string ToStringName = "ToString";
            stringStmts.Add(SyntaxFactory.ReturnStatement(
                SyntaxFactory.InvocationExpression(
                    SyntaxFactory.MemberAccessExpression(
                        SyntaxKind.SimpleMemberAccessExpression,
                        SyntaxFactory.IdentifierName(SBName),
                        SyntaxFactory.IdentifierName(ToStringName)))));

            classMembers.Add(SyntaxFactory.ConstructorDeclaration(name)
                .WithModifiers(SyntaxFactory.TokenList(
                    SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
                .WithParameterList(SyntaxFactory.ParameterList(
                    SyntaxFactory.SeparatedList(ctorParams)))
                .WithBody(SyntaxFactory.Block(ctorStmts)));

            classMembers.Add(SyntaxFactory.MethodDeclaration(stringType, ToStringName)
                .WithModifiers(SyntaxFactory.TokenList(
                    SyntaxFactory.Token(SyntaxKind.PublicKeyword),
                    SyntaxFactory.Token(SyntaxKind.OverrideKeyword)))
                .WithBody(SyntaxFactory.Block(stringStmts)));

            MemberDeclarationSyntax genericClass = SyntaxFactory.ClassDeclaration(name)
                .WithModifiers(SyntaxFactory.TokenList(
                    SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
                .WithTypeParameterList(SyntaxFactory.TypeParameterList(
                    SyntaxFactory.SeparatedList(typeParams)))
                .WithMembers(SyntaxFactory.List(classMembers));

            ObjectCreationExpressionSyntax initClass =
                SyntaxFactory.ObjectCreationExpression(
                    SyntaxFactory.GenericName(name)
                        .WithTypeArgumentList(
                            SyntaxFactory.TypeArgumentList(
                                SyntaxFactory.SeparatedList(testTypeArgs))))
                    .WithArgumentList(
                        SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(testArgs)));
            ExpressionSyntax testToString = SyntaxFactory.InvocationExpression(
                SyntaxFactory.MemberAccessExpression(
                    SyntaxKind.SimpleMemberAccessExpression,
                    initClass,
                    SyntaxFactory.IdentifierName(ToStringName)));
            MemberDeclarationSyntax testMethod =
                SyntaxFactory.MethodDeclaration(stringType, "Test")
                    .WithModifiers(SyntaxFactory.TokenList(
                        SyntaxFactory.Token(SyntaxKind.PublicKeyword),
                        SyntaxFactory.Token(SyntaxKind.StaticKeyword)))
                    .WithBody(SyntaxFactory.Block(
                        SyntaxFactory.ReturnStatement(testToString)));

            MemberDeclarationSyntax testClass = SyntaxFactory.ClassDeclaration(name)
                .WithModifiers(SyntaxFactory.TokenList(
                    SyntaxFactory.Token(SyntaxKind.PublicKeyword),
                    SyntaxFactory.Token(SyntaxKind.StaticKeyword)))
                .WithMembers(SyntaxFactory.SingletonList(testMethod));

            UsingDirectiveSyntax[] usings = new UsingDirectiveSyntax[]
            {
                SyntaxFactory.UsingDirective(SyntaxFactory.IdentifierName("System")),
                SyntaxFactory.UsingDirective(SyntaxFactory.IdentifierName("System.Text")),
            };

            MemberDeclarationSyntax[] classes = new MemberDeclarationSyntax[]
            {
                genericClass,
                testClass
            };

            return SyntaxFactory.CompilationUnit()
                .WithUsings(SyntaxFactory.List(usings))
                .WithMembers(SyntaxFactory.List(classes))
                .NormalizeWhitespace();
        }

The resulting compilation unit looks like this when passing name “Generic” and count 4:

using System;
using System.Text;

public class Generic<T0, T1, T2, T3>
{
    private readonly T0 t0;
    private readonly T1 t1;
    private readonly T2 t2;
    private readonly T3 t3;
    public Generic(T0 t0, T1 t1, T2 t2, T3 t3)
    {
        this.t0 = t0;
        this.t1 = t1;
        this.t2 = t2;
        this.t3 = t3;
    }

    public override string ToString()
    {
        StringBuilder sb = new StringBuilder();
        sb.Append(this.t0);
        sb.Append(this.t1);
        sb.Append(this.t2);
        sb.Append(this.t3);
        return sb.ToString();
    }
}

public static class Generic
{
    public static string Test()
    {
        return new Generic<string, string, string, string>("00000000", "00000001", "00000002", "00000003").ToString();
    }
}

Note that the generated constructor’s parameter list is a one-to-one match with the number of type parameters. So with this design, the limit on number of method input parameters will actually be hit first. Again, according to Stack Overflow, this would give us a theoretical upper bound of 65,535 parameters (which would in practice be less due to runtime stack size limits).

Setting that aside for a minute, let’s simply calculate a rough benchmark of how expensive Generic.Test() is for various type parameter counts. The benchmark code is as follows:

        private static void Measure(int iterations)
        {
            Stopwatch stopwatch = Stopwatch.StartNew();
            long sum = 0;
            for (int i = 0; i < iterations; ++i)
            {
                sum += Generic.Test().Length;
            }

            double elapsedMS = stopwatch.ElapsedMilliseconds;
            Console.WriteLine("sum={0} / {1:0.000} ms per iteration", sum, elapsedMS / iterations);
        }

Here we are just calling the Generic.Test() method and doing something meaningful with the result to avoid any aggressive optimizations.

These are the results I got on my system when running 1,000,000 iterations each. (The time measurements were effectively zero until about 64 type parameters):

Type param count Time per iteration (ms)
64 0.001
128 0.002
256 0.005
512 0.010
1024 0.024
2048 0.051
4096 0.102
8192 (crashes)

I should note that at 4096 parameters, code generation in Roslyn and navigation of the file in Visual Studio became quite noticeably slower. The compilation step was still relatively fast — no more than a few seconds. The compiler is fast (DoS attacks notwithstanding). At 8192 parameters, compilation took somewhat longer (~10 seconds) and the program would not run anymore; it crashed with System.InvalidProgramException: Common Language Runtime detected an invalid program. I suspect this is due to stack size limits. I also verified that the program does not crash when using 8191 type parameters of string (~0.269 ms per iteration), indicating we are certainly hitting a short integer bound somewhere.

To see if I could push this limit any farther, I made minor changes to the code generator to use the smallest possible data type, byte, for the type parameters instead of string. But even with that modification, the program would still not run with 8192 type parameters. Based on inspection of the disassembled IL, I presume passing 8192 32-bit integer literals (since there are no shorter literals) still funs afoul of the hard limit. There are, however, Boolean literals, and only two of them! Could that be the key to unlocking a higher limit? Alas, the naïve way of coding this up (passing ‘true’ and ‘false’ literals) still produces IL using 4-byte integer constants:

.method public hidebysig static string  Test() cil managed
{
// . . .
  IL_0000:  ldc.i4.1
  IL_0001:  ldc.i4.0
  IL_0002:  ldc.i4.1
  IL_0003:  ldc.i4.0
// . . .

How about ditching literals and instead declaring two const fields of type bool, one for true and another for false and passing those instead? No dice. The compiler still uses 4-byte integer constants for each Boolean parameter passed. To go farther, we’ll probably have to give up on passing the values in a single method. But I’ll simply stop here for today.

My tentative conclusion is that the string manipulation operations in this scenario make up the majority of the runtime cost (in particular, the copying/allocation of string buffers using StringBuilder). Even passing the parameters at all presents significant challenges. I would have to say that fundamental generic type parameter limits would hence never be the dominating factor in any program using classes containing dozens, hundreds, or even thousands of type parameters. Of course, you must measure for your specific scenario and draw your own conclusions in context — assuming there is a context where generic types this massive make any sense.

Leave a Reply

Your email address will not be published. Required fields are marked *