How Generics Differ in Java and C#

Written by pathiknd | Published 2023/01/11
Tech Story Tags: java | csharp | programming | software-development | software-engineering | java-programming | software-engineer | programming-languages

TLDRJava and C# implement Generics support very differently. Type Erasure method used in Java results in limitations on Generics usage compared to C#. C# compiler as well as the Runtime (CLR) understands Generics. That’s why C# is able to provide performance benefits and better support for run time operations.via the TL;DR App

Generics are very widely used in both Java and C# programming, especially in frameworks and libraries. Generics primarily provide type safety and improved performance by avoiding the need to cast variables.

Java and C# Generics look very similar at the syntactical level but they work differently. The difference in behavior is because of how the support for Generics is implemented in both these languages. We'll explore this in this post.

Generics Implementation

Generics support in a language is required at both Compile time and Run time. Let's use an example to understand this better.

A library named common-lib declares a Generic type as shown below. This library is built and published which is then used in other programs.

public class GenericTest<T> {
    private T _ref;
}

An application named demo-app uses the common-lib.

public class App{
    public static void main(String[] args){
        GenericTest<MyClass> t = new GenericTest<MyClass>();
        GenericTest<SomeClass> s = new GenericTest<SomeClass>();
        //s = t; //allowed? type safety?
    }
}

common-lib and demo-app are different artifacts. When demo-app is compiled, the compiler needs to know that GenericTest<T> is a Generic type so it should be treated differently. Therefore when common-lib is compiled, the compiled output should have information about the Generic type. This will allow the compiler to ensure type safety when compiling the demo-app. Both Java and C# guarantee compile-time type safety so this is important.

Reflection is supported by both Java and C#. Reflection APIs allow accessing type information at run time. Reflection also supports creating new objects, call methods on an object, etc. at run time. To support all these operations on Generic types, some support for Generics should be present at the run time also.

Java Generic Code Lifecycle

Compile Time

Java uses the concept of Type Erasure to support Generics in Java. Through Type Erasure, the Java compiler converts all Generic type references to Non-Generic types at the time of compilation. The type Erasure approach was used to provide backward compatibility so that Non-Generic types can be passed to newer code using generics. Let's understand with an example.

The following is a simple generic class.

public class GenericTest<T> {

	private T _ref;
	
	public <T1 extends Comparable<T>> boolean isEqual(T1 obj){
		return obj.compareTo(this._ref) == 0 ? true : false;
	}
}

When this class is compiled, the Generic type parameters are removed and replaced with Non-Generic equivalents. Following is the generated byte code - shown by Bytecode Viewer:

The following snippet lists the difference between source code and the compiled version - see comments in the code:

//source code
public class GenericTest<T>
//compiled code - GenericTest<T> became just GenericTest
public class generics/example/application/GenericTest

//source code
private T _ref;
//compiled code - T was replaced with Object
private java.lang.Object _ref;

//source code
public <T1 extends Comparable<T>> boolean isEqual(T1 obj)
//compiled code - T1 became Comparable because
//of constraint that T1 should be subtype of Comparable<T>
public isEqual(java.lang.Comparable arg0)

Compiled Java code does not have any trace of Generic types. Everything is mapped to a raw Java type. One of the side effects of Type Erasure is that GenericTest<T> and GenericTest are the same after Type Erasure by the compiler, so it is not possible to have both in the same package.

Run Time

At the JVM level, there are no Generic types. As explained in the earlier section, the Java compiler removes all traces of Generic types so the JVM doesn't have to do anything different to handle Generic types.

C# Generic Code Lifecycle

Compile Time

Following is the equivalent C# code of the example used above in Java:

public class GenericTest<T>
{
    private T _ref;

    public bool IsEqual<T1>(T1 obj) where T1 : IComparable<T>
    {
        return obj.CompareTo(this._ref) == 0 ? true : false;
    }
}

When the above code is compiled, the C# compiler retains the generic type information, which is used by the .Net Runtime to support generics.

The class metadata of compiled library maintains Generics information. A peek into compiled library metadata using [IL Disassembler] (https://docs.microsoft.com/en-us/dotnet/framework/tools/ildasm-exe-il-disassembler):

The IL Code (same as byte code of Java) of IsEqual method has Generics information - see underlined sections:

Run Time

.Net Runtime (CLR) uses the Generic type information in the compiled code to create concrete types at runtime. Let's understand with an example.

The following code creates three objects of GenericTest<T> for three different types.

GenericTest<int> intObj = new GenericTest<int>();
GenericTest<double> doubleObj = new GenericTest<double>();
GenericTest<string> strObj = new GenericTest<string>();

When this code is run, the .Net Runtime would dynamically create three concrete types based on the original GenericTest<T> Generic type definition in the IL Code:

  1. GenericTest<int>: T replaced with int. This type will be used to create all new objects of type GenericTest<int>

  2. GenericTest<double>: T replaced with double. This type will be used to create all new objects of type GenericTest<double>

  3. GenericTest<Object>: T replaced with System.Object. This type will be used to create all new objects of any reference type like GenericTest<String>, GenericTest<FileStream>, GenericTest<SomeClass>, etc.

.Net Runtime (CLR) creates a new type for each primitive \ value type, which gives both type safety and performance benefit by avoiding boxing operations. For reference type, there is only type and the .Net Runtime type safety mechanism ensures type safety.

Differences in Behavior

Due to the nature of implementation, there are a few differences between how Generics work in Java and C#:

  • Primitive Types Support

    • Java does not support primitive types in Generics because Type Erasure cannot work in that case.

    • C# supports primitive types (or Value Types in C#) in Generics which gives two benefits:

      • Type safety

      • Performance benefits by removing boxing and unboxing needs. This is achieved by .Net Runtime's dynamic concrete type creation as explained above.

    • Because of this limitation in Java, there are a number of Functional Interfaces like IntFunction, LongFunction, etc. If primitive types can be supported by Generics, only one interface can be enough:

      public interface Function<T,R> {
      	R apply(T value);
      }
      

    • There is an open item JEP 218: Generics over Primitive Types to support primitive types in Java generics.

  • Performance

    • Type Erasure inserts casts wherever required to ensure type safety, but this will add to performance cost rather than improving performance by avoiding casting with Generics. E.g.,

      public void test() {
      	ArrayList<MyClass> al = new ArrayList<MyClass>();
      	al.add(new MyClass());
      	//Compiler would add cast
      	MyClass m = al.get(0); //source
      	//MyClass m = (MyClass)al.get(0) //compiled
      	//this will be fine as al.get(0) anyway returns Object.
      	Object o = al.get(0);
      }
      

  • Run time operations

    • If you have to do run-time type checks on T (like is T instance of IEnumerable), reflect on Generic types, or do new T() kind of operations, it is either not possible in Java or you have to use workarounds. Let's see an example.

      • We will write a function that will deserialize a JSON string into an object using Generic parameters.

      • Following is the C# code:

        public static T getObject<T>(string json)
        {
            return (T)JsonConvert.DeserializeObject(json, typeof(T));
        }
        // usage
        // MyClass m = getObject<MyClass>("json string");
        

      • But same thing can't work in Java because T.class would not compile.

        public static <T> T getObject(String json) {
        	ObjectMapper m = new ObjectMapper();
        	return (T)m.readValue(json, T.class);
        }
        

      • To make the above code work, getObject method would have to take the Type as input parameter.

        public static <T> T getObject(String json, Type t) {
        	ObjectMapper m = new ObjectMapper();
        	return (T)m.readValue(json, t.getClass());
        }
        //usage
        // MyClass m = getObject<MyClass>("json string", MyClass.class);
        

Summary

Java and C# implement Generics support very differently. Type Erasure method used in Java results in limitations on Generics usage compared to C#. C# compiler as well as the Runtime (CLR) understands Generics. That’s why C# is able to provide performance benefits and better support for run-time operations.

References


Also published here.


Written by pathiknd | Software Developer
Published by HackerNoon on 2023/01/11