Dnešní video o C# pro mírně pokročilé se zabývá rozhraními IEquatable, IComparable, operátory a type cast operátory. Všechny tyto věci se hodí, když chcete vytvořit třídu, reprezentující nějaký "reálný" fenomén a chcete s ní pohodlně pracovat. Zde budeme vytvářet třídu pro reprezentaci hodnoty úhlu.

Rozhraní IEquatable

Rozhraní IEquatable a jeho generická varianta IEquatable<T> definují různé overloady metody Equals, která umožňuje rozhodnout, zda jsou si dvě různé instance téže třídy rovny. Nikoliv z hlediska C# a .NET (např. zda se jedná o tutéž instanci nebo zda mají jejich vlastnosti tutéž hodnotu), ale z hlediska vnitřní logiky oné třídy samé.

Řeč je z krátkosti jenom o třídách, ale i struct nebo record může implementovat interface a článek se na ně vztahuje také.

Například naše třída Angle, reprezentující rovinný úhel, může být zadána několika způsoby. V desetinných stupních, minutách nebo vteřinách, případně v radiánech. Platí tedy, že 12,5° a 12°30' nebo 90° a π / 2 rad reprezentují tytéž hodnoty a toto rozhraní to dokáže postihnout.

Rozhraní IComparable

I rozhraní IComparable má generickou variantu IComparable<T> a je o něco schopnější, než to předchozí. Metoda CompareTo dokáže porovnat dvě instance téže třídy a vrátit -1, 0 nebo 1 podle toho, která z nich je větší. Opět platí, že co je větší a co menší rozhoduje vnitřní logika dané třídy.

Implementace metod rozhraní IEquatable a IComparable umožňuje jednoduše a abstraktně pracovat s "hodnotovými" třídami pomocí univerzálních metod. Lze je příkladmo řadit a podobně.

Operátory

Pomocí klíčového slova operator lze definovat vlastní operátory. Ty mohou být porovnávací (==, !=, <, <=...) tak matematické (+, -, *, /, %). Implementace těch prvních je obecně vhodná ve chvíli, kdy implementujete shora uvedená rozhraní, aby a.Equals(b) dávalo tentýž výsledek jako a == b. Matematické operátory je vhodné implementovat pokud hrozí, že s instancemi třídy budete chtít počítat. Což se u úhlu docela hodí, protože sečíst či odečíst dva úhly nebo násobit a dělit úhel konstantou může být užitečné.

Poslední skupinou jsou operátory pro přetypování (type cast operátory). Ty umožňují implicitně nebo explicitně přetypovat třídu na jiný typ. Implicitní operátory by se měly používat tam, kde přetypováním nedochází ke ztrátě informace a jejich použití je extrémně jednoduché. Díky nim lze například použít naši třídu Angle kdekoliv, kde se jinak používá typ double. To, mimo jiné, činí z implementace předchozích rozhraní a operátorů akademické cvičení, protože k dosažení téžě funkčnosti v tomto případě stačí přetypování. Explicitní operátor pro přetypování vyžaduje výslovnou konstrukci (např. var b = (NovyTyp)a) a používá se tehdy, pokud konverze může způsobit ztrátu nějaké informace.

Zdrojový kód třídy Angle

Pokud vás zajímá pozadí metody ToString a rozhraní IFormattable, vytvořil jsem o něm již dříve separátní článek a video.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
using static System.Math;

namespace OperatorsDemo {
    public struct Angle : IFormattable, IEquatable<Angle>, IComparable, IComparable<Angle> {

        // Properties

        public double Degrees { get; }

        public int WholeDegrees => (int)this.Degrees;

        public double Minutes => (Abs(this.Degrees) - (double)Abs(this.WholeDegrees)) * 60d;

        public int WholeMinutes => Abs((int)this.Minutes);

        public double Seconds => (Abs(this.Minutes) - Abs(this.WholeMinutes)) * 60d;

        public int WholeSeconds => (int)this.Seconds;

        // Constructors

        public Angle(double deg) {
            this.Degrees = deg;
        }

        public Angle(int deg, double min) {
            if (min < 0 || min >= 60) throw new ArgumentOutOfRangeException(nameof(min));

            this.Degrees = deg + min / 60d;
        }

        public Angle(int deg, int min, double sec) {
            if (min < 0 || min >= 60) throw new ArgumentOutOfRangeException(nameof(min));
            if (sec < 0 || sec >= 60) throw new ArgumentOutOfRangeException(nameof(sec));

            this.Degrees = deg + min / 60d + sec / 3600d;
        }

        // General methods

        public Angle Add(Angle a) => new(this.Degrees + a.Degrees);

        public Angle Subtract(Angle a) => new(this.Degrees - a.Degrees);

        public Angle ToNominal() => new(this.Degrees % 360);

        // Radian conversions

        public double ToRadians() => this.Degrees * PI / 180;

        public static Angle FromRadians(double rad) => new(rad * 180 / PI);

        // ToString & IFormattable

        public string ToString(string format, IFormatProvider formatProvider) {
            format ??= "S";

            // double formatting helper functions
            string formatComponent(double value, string format = "F0") => (value < 10 ? "0" : string.Empty) + value.ToString(format.Length == 1 ? "F" : "F" + format[1..], formatProvider);

            // Integer fomatting
            if (format == "D") return $"{this.Degrees.ToString("F0", formatProvider)}°";
            if (format == "M") return $"{this.WholeDegrees}°{formatComponent(this.Minutes)}'";
            if (format == "S") return $"{this.WholeDegrees}°{formatComponent(this.WholeMinutes)}'{formatComponent(this.Seconds)}\"";

            // Decimal formatting
            if (format.StartsWith("d")) return $"{this.Degrees.ToString("F", formatProvider)}°";
            if (format.StartsWith("m")) return $"{this.WholeDegrees}°{formatComponent(this.Minutes, format)}'";
            if (format.StartsWith("s")) return $"{this.WholeDegrees}°{formatComponent(this.WholeMinutes)}'{formatComponent(this.Seconds, format)}\"";

            throw new FormatException();
        }

        public override string ToString() => this.ToString("S", null);

        // IEquatable

        public override bool Equals(object obj) {
            if (obj is null) throw new ArgumentNullException(nameof(obj));
            return obj is Angle a ? this.Degrees.Equals(a.Degrees) : throw new ArgumentException(null, nameof(obj));
        }

        public bool Equals(Angle other) => this.Degrees.Equals(other.Degrees);

        public override int GetHashCode() => this.Degrees.GetHashCode();

        // IComparable

        public int CompareTo(object obj) {
            if (obj is null) throw new ArgumentNullException(nameof(obj));
            return obj is Angle a ? this.Degrees.CompareTo(a.Degrees) : throw new ArgumentException(null, nameof(obj));
        }

        public int CompareTo(Angle other) => this.Degrees.CompareTo(other.Degrees);

        // Arithmetic operators

        public static Angle operator +(Angle a1, Angle a2) => a1.Add(a2);

        public static Angle operator -(Angle a1, Angle a2) => a1.Subtract(a2);

        public static Angle operator *(Angle a, double factor) => new(a.Degrees * factor);

        public static Angle operator /(Angle a, double factor) => new(a.Degrees / factor);

        // Compare operators

        public static bool operator ==(Angle a1, Angle a2) => a1.Degrees == a2.Degrees;

        public static bool operator !=(Angle a1, Angle a2) => a1.Degrees != a2.Degrees;

        public static bool operator <(Angle a1, Angle a2) => a1.Degrees < a2.Degrees;

        public static bool operator >(Angle a1, Angle a2) => a1.Degrees > a2.Degrees;

        public static bool operator <=(Angle a1, Angle a2) => a1.Degrees <= a2.Degrees;

        public static bool operator >=(Angle a1, Angle a2) => a1.Degrees >= a2.Degrees;

        // Type cast operators (make arithmetic and compare operators unnecessary)

        public static implicit operator double(Angle a) => a.Degrees;

        public static implicit operator Angle(double d) => new(d);

    }
}