C# operator constraints

Avevo questo post in cantiere da qualche tempo, da quando un paio di miei colleghi mi hanno esternato le loro perplessità su un particolare behavior di C# compiler.
Partiamo dal primo snippet:

   1:  Boolean AreEqual<T>(T a, T b)
   2:  {
   3:      return a == b;
   4:  }

Si noterà subito che il codice in oggetto non compila.
Limitandosi ad aggiungere il constraint "class", la compilazione va a buon fine.

   1:  Boolean AreEqual<T>(T a, T b) where T : class
   2:  {
   3:      return a == b;
   4:  }

Il motivo per il quale il primo snippet non viene compilato lo si può appurare consultando la documentazione MSDN e, nello specifico:
"For predefined value types, the equality operator (==) returns true if the values of its operands are equal, false otherwise. For reference types other than string, == returns true if its two operands refer to the same object. For the string type, == compares the values of the strings".

L'elemento sul quale dobbiamo spostare la nostra attenzione è ciò che la documentazione definisce come "predefined value type".
Personalmente, trovo che qualche Unit Test rappresenti la soluzione migliore per chiarirsi e fissarsi questi concetti. Vediamo quindi di condurre qualche test con la struttura Int32.

   1:  [TestMethod]
   2:  public Int32Equality()
   3:  {
   4:      var int1 = 2;
   5:      var int2 = 2;
   6:      Assert.AreEqual(int1, int2);
   7:      Assert.IsTrue(int1 == int2);
   8:  }

Il test compila e passa e la domanda, a questo punto, è legittima, soprattutto se si osserva la struttura System.Int32 con uno strumento quale "Reflector". Si può notare, infatti, che la medesima non offre gli overload per l'operatore Equals. Nella sua "bibbia", Jeffrey Ritcher aggiunge una breve nota su tale questione:
Note If you examine the core numeric types (Int32, Int64, UInt32, and so on) in the Framework Class Library (FCL), you’ll see that they don’t define any operator overload methods. The reason they don’t is that compilers look specifically for operations on these primitive types and emit IL instructions that directly manipulate instances of these types. If the types were to offer methods and if compilers were to emit code to call these methods, a run-time performance cost would be associated with the method call. Plus, the method would ultimately have to execute some IL instructions to perform the expected operation anyway. This is the reason why the core FCL types don’t define any operator overload methods. Here’s what this means to you: If the programming language you’re using doesn’t support one of the core FCL types, you won’t be able to perform any operations on instances of that type.

Giusto per esercizio, cerchiamo conferma di tutto ciò definendo uno "User-defined value type", analizzandolo nel medesimo contesto.

   1:  struct NonPredefinedValueType : IEquatable<NonPredefinedValueType>
   2:  {
   3:      public Int32 SomeValue;
   4:   
   5:      public override Boolean Equals(Object obj)
   6:      {
   7:          return obj is NonPredefinedValueType
   8:              ? Equals((NonPredefinedValueType) obj)
   9:              : base.Equals(obj);
  10:      }
  11:   
  12:      public Boolean Equals(NonPredefinedValueType other)
  13:      {
  14:          return other.SomeValue == SomeValue;
  15:      }
  16:   
  17:      public override Int32 GetHashCode()
  18:      {
  19:          return SomeValue;
  20:      }
  21:  }

Scriviamo dunque il test:

   1:  [TestMethod]
   2:  public NonPredefinedValueTypeEquality()
   3:  {
   4:      var value1 = new NonPredefinedValueType { SomeValue = 2 };
   5:      var value2 = new NonPredefinedValueType { SomeValue = 2 };
   6:      Assert.AreEqual(value1, value2);
   7:      Assert.IsTrue(value1 == value2);
   8:  }

Come si può notare, l'ultimo statement non compila, indicando che non è possibile applicare l'operatore "==" tra i due operandi. Alla luce di quanto emerso in precedenza, questo errore non dovrebbe coglierci di sorpresa. Difatti, stiamo operando con una struttura non-primitiva e, come si è visto, non abbiamo a disposizione alcun overload di operatore "preconfezionato".
Proviamo quindi ad aggiungere alla nostra struttura l'overload per l'operatore "==":

   1:  struct NonPredefinedValueType : IEquatable<NonPredefinedValueType>
   2:  {
   3:      public Int32 SomeValue;
   4:   
   5:      public override Boolean Equals(Object obj)
   6:      {
   7:          return obj is NonPredefinedValueType
   8:              ? Equals((NonPredefinedValueType) obj)
   9:              : base.Equals(obj);
  10:      }
  11:   
  12:      public Boolean Equals(NonPredefinedValueType other)
  13:      {
  14:          return other.SomeValue == SomeValue;
  15:      }
  16:   
  17:      public override Int32 GetHashCode()
  18:      {
  19:          return SomeValue;
  20:      }
  21:   
  22:      public static Boolean operator ==(NonPredefinedValueType a, NonPredefinedValueType b)
  23:      {
  24:          return a.Equals(b);
  25:      }
  26:   
  27:      public static Boolean operator !=(NonPredefinedValueType a, NonPredefinedValueType b)
  28:      {
  29:          return !(a == b);
  30:      }
  31:  }

Come si può notare, il test questa volta compila e passa.

   1:  [TestMethod]
   2:  public void NonPredefinedValueTypeEquality()
   3:  {
   4:      var value1 = new NonPredefinedValueType { SomeValue = 2 };
   5:      var value2 = new NonPredefinedValueType { SomeValue = 2 };
   6:      var value3 = new NonPredefinedValueType { SomeValue = 3 };
   7:      Assert.AreEqual(value1, value2);
   8:      Assert.AreNotEqual(value1, value3);
   9:      Assert.IsTrue(value1 == value2);
  10:      Assert.IsTrue(value1 != value3);
  11:  }

Ok, niente di così trascendentale, ma eseguiamo una callback al primo snippet e chiediamoci se è possibile, in qualche modo, evitare il constraint a "class" senza rinunciare ai generics. Beh, la prima idea che ti può venire in mente è che il ricorso a DLR "sposti" il problema dalla fase di compilazione a quella di runtime. In sostanza:

   1:  Boolean AreEqual<T>(T a, T b)
   2:  {
   3:      return (dynamic)a == b;
   4:  }

Ne ho discusso con i miei amici di DotNetToscana e i pareri sono piuttosto discordanti. Personalmente, trovo che il prezzo che si paga per garantire un maggior grado di astrazione in qualche contesto possa essere accettabile. Ma sono curioso di sentire altri pareri! :-)

UPDATE:
Spaccabit aggiunge un interessante elemento che ci porta ad ampliare ulteriormente la discussione.
Sostanzialmente, a.Equals(b) va sempre a chiamare il metodo con la firma "Boolean Equals(Object obj)". Quindi, indipendentemente dal fatto che il tipo del parametro generico T implementi o meno IEquitable<T>, la chiamata va sempre al metodo in oggetto.
A questo punto, c'è da dire che se il tipo è disegnato "male" ciò può rappresentare un problema piuttosto serio.
Al contrario, "EqualityComparer<T>.Default.Equals(a, b)" verifica in prima istanza che T implementi IEquitable<T>. In tal caso, esegue l'implementazione di IEquitable<T>.
Mi viene da dire, quindi, che "EqualityComparer<T>.Default.Equals(a, b)" possa fungere un pò da "salvagente", come giustamente fa notare Spaccabit.
Quello che volevo aggiungere è che:

  • Se si vuole essere scrupolosissimi, EqualityComparer<T>.Default potremmo considerarla una dipendenza. Anche se, così su due piedi, direi proprio che si tratta proprio di cercare "il pelo nell'uovo".
  • Il discorso che volevo fare aveva uno spettro leggermente pò più ampio.
    Ad esempio:
       1:  static T Sum<T>(T a, T b)
       2:  {
       3:      return a + b;
       4:  }
«December»
SunMonTueWedThuFriSat
27282930123
45678910
11121314151617
18192021222324
25262728293031
1234567