İlkay İlknur

C# 9.0 İle Immutable Data İle Çalışma (Recordlar ve Init-Only Propertyler)

Ağustos 27, 2020

.NET 5.0'in final release'inin ayak seslerinin gittikçe yaklaşmasıyla beraber C#'ın da bir sonra versiyonu olan 9.0'dan yavaş yavaş bahsetmemizin zamanı geldi. Artık bildiğimiz üzere her C# versiyonuyla beraber programlama dilini dizayn eden ekip, topluluğun da katkısıyla bir tema üzerinde yoğunlaşıp bu tema üzerinden özellikleri dile eklemekte. Eski versiyonlardan bu zamana kadar olan versiyonlara bakarsak yazılımcıların işini kolaylaştıracak ufak özelliklerin yanı sıra bir tema etrafında toplanan büyük özelliklerin dile eklendiğini kolayca görebiliriz.(LINQ,Dinamik programlama,async/await,nullable reference types... gibi).

C# 9.0 ile beraber neler gelecek diye baktığımızda yine ufak tefek kolaylaştırıcı özelliklerin yanı sıra tema olarak bu versiyonda immutable data'nın belirlendiği görüyoruz. Programlama dili tarafından immutable tiplerin implementasyonunun kolaylaştırılması aslında uzun zamandan beri ekibin gündeminde olan bir konuydu. Ancak öncelikler sebebiyle C# 9.0 ile beraber bu tema implemente edilebildi.

Şimdi gelin öncelikle init-only propertyleri sonra da record kavramını bu yazıda inceleyelim.

Init-Only Properties ve Init Accessor

Bugün immutable bir propertyi getter-only-properties özelliğini kullanarak tanımladığımızda bu propertye değer ataması sadece constructor üzerinden yapılabilmekte.

public class Car
{
    public string Brand { get; }
    public string Model { get; }
 
    public Car(string brand, string model)
    {
        Brand = brand;
        Model = model;
    }
}

C# içerisinde bir nesne yaratırken sıklıkla kullandığımız özelliklerden biri object initializerlar. Object initializerlarla hızlı bir şekilde object içerisindeki istediğimiz propertylere istediğimiz değeri atayabiliyoruz. Ancak yukarıdaki gibi property tanımlaması yaptığımızda object initializerları kullanmamız mümkün değil ve constructor yazmak durumunda kalıyoruz.

C# 9.0 ile beraber gelen init accessor'ı ile beraber propertylerimiz constructorın yanı sıra object initializerlar kullanılarak da initialize edilebilmekte. Bu durumda yukarıdaki Car class'ını şu şekilde güncelleyebiliriz.

public class Car
{
    public string Brand { getinit; }
    public string Model { getinit; }
}

Yeni bir nesne yaratmak istersek,

var car = new Car()
{
    Brand ="BMW",
    Model = "2020"
};

Eğer bu şekilde getter-only propertyleri kullanmak yerine arkada saklanan readonly fieldı kendimiz tanımlayıp, init esnasında da araya girmek istersek bunu yapmamız da mümkün.

public class Car
{
    private readonly string brand;
    private readonly string model;
 
    public string Brand
    {
        get => brand;
        init => brand = value;
    }
    public string Model
    {
        get => model;
        init => model = value;
    }
}

Uzun Süredir Beklenen Özellik : Recordlar

Eğer daha önce functional programmingle uğraştıysanız o dünyadaki record kavramına aşinasınızdır. Recordları içerisinde data barından lightweight classlar olarak düşünebiliriz. Recordların classlardan farkı ise içerisinde bulunan datanın öne çıkması. Örneğin, iki recordı karşılaştırırken bu recordların aynı referansa sahip olması değil aynı dataya sahip olması dikkate alınmakta.

Şimdi gelin yukarıdaki Car classını recordları kullanarak implemente edelim.

public record Car
{
    public string Brand { getinit; }
    public string Model { getinit; }
}

Şu anda baktığımızda aslında sadece class keywordu yerine record keywordu geldi gibi duruyor. Peki compiler arka planda record keywordünü görünce neler yapıyor bir de ona bakalım.

public class Car : IEquatable<Car>
{
	protected virtual Type EqualityContract
	{
		[System.Runtime.CompilerServices.NullableContext(1)]
		[CompilerGenerated]
		get
		{
			return typeof(Car);
		}
	}
 
	public string Brand
	{
		get;
		set;
	}
 
	public string Model
	{
		get;
		set;
	}
 
	[System.Runtime.CompilerServices.NullableContext(2)]
	public static bool operator !=(Car? r1, Car? r2)
	{
		return !(r1 == r2);
	}
 
	[System.Runtime.CompilerServices.NullableContext(2)]
	public static bool operator ==(Car? r1, Car? r2)
	{
		return (object)r1 == r2 || (r1?.Equals(r2) ?? false);
	}
 
	public override int GetHashCode()
	{
		return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Brand)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Model);
	}
 
	public override bool Equals(object? obj)
	{
		return Equals(obj as Car);
	}
 
	public virtual bool Equals(Car? other)
	{
		return (object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<string>.Default.Equals(Brand, other!.Brand) && EqualityComparer<string>.Default.Equals(Model, other!.Model);
	}
 
	public virtual Car<Clone>$()
	{
		return new Car(this);
}

Compiler tarafından arka planda yaratılan sınıfa baktığımızda compilerın bir sınıf yaratıp sonra da IEquatable<T> interface'ini implemente ettirdiğini görüyoruz. Böylece bu record üzerinden yaratılacak iki nesne karşılaştırıldığında bu nesneler referans olarak değil içerisindeki veriler baz alınarak karşılaştırılmış olacak.

var car = new Car()
{
    Brand = "BMW",
    Model = "2020"
};
 
var car2 = new Car()
{
    Brand = "BMW",
    Model = "2020"
};
 
Console.WriteLine($"IsEqual:{car == car2}");

Kodu çalıştırırsak

with Expressions

Immutable tiplerle çalışırken nesne üzerinde bir değişiklik yapmak istediğimizde ilgili değişikliği içeren yeni bir nesne yaratmamız gerekiyor. Bunun .NET içerisinde en güzel örneği stringler. String üzerinde değişiklik yapamıyoruz ama değişikliği içeren yeni bir string yaratabiliyoruz.

C# 9.0'dan önce immutable nesne üzerinden değişiklik yaparak yeni bir nesne yaratmak istediğimizde With metotları yazıp bunları kullanarak yeni nesneler yaratabiliyorduk.

Örneğin,

public class Car
{
    public string Brand { getinit; }
    public string Model { getinit; }
 
    public Car With(string brand)
    {
        return new Car
        {
            Brand = brand,
            Model = this.Model
        };
    }
}

Kullanımı

var car = new Car()
{
    Brand = "BMW",
    Model = "2020"
};
 
var newCar = car.With("Mercedes");

Ancak tabi bu metotlari yazmak ve maintain etmek her zaman kolay olmuyor. C# 9.0 ile beraber gelen with expressionslarla With metotları yazmaktan da kurtuluyoruz.

var car = new Car()
{
    Brand = "BMW",
    Model = "2020"
};
 
var newCar = car with { Brand = "Mercedes" };

Positional Records

Recordları yukarıdaki gibi object initializerlar ile initialize edebileceğimiz gibi recordlar içerisindeki fieldları sırayla initialize edip aynı zamanda sırayla da deconstruct etmek isteyebiliriz. Bu şekilde davranışlara sahip olan recordlara positional records deniyor. Bir üst kısımda bahsettiğimiz record tipi ise nominal recordlar.

Car recordını positional olarak tanımlamak istersek.

public record Car(string Brand, string Model);

Recordu positional olarak tanımladıktan sonra artık recordu object initializer yerine constructor ile yaratabilir sonrasında da içerisindeki fieldları tanımlamada belirttiğimiz sırayla deconstruct edebiliriz.

İlk olarak positional recordlarda compiler constructor ve deconstruct metodunu arka planda nasıl yaratıyor ona bakalım.

public class Car : IEquatable<Car>
{
    protected virtual Type EqualityContract
    {
        [System.Runtime.CompilerServices.NullableContext(1)]
        [CompilerGenerated]
        get
        {
            return typeof(Car);
        }
    }
 
    public string Brand
    {
        get;
        set;
    }
 
    public string Model
    {
        get;
        set;
    }
 
    public Car(string Brand, string Model)
    {
        this.Brand = Brand;
        this.Model = Model;
        base..ctor();
    }
 
    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator !=(Car? r1, Car? r2)
    {
        return !(r1 == r2);
    }
 
    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator ==(Car? r1, Car? r2)
    {
        return (object)r1 == r2 || (r1?.Equals(r2) ?? false);
    }
 
    public override int GetHashCode()
    {
        return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Brand)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Model);
    }
 
    public override bool Equals(object? obj)
    {
        return Equals(obj as Car);
    }
 
    public virtual bool Equals(Car? other)
    {
        return (object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<string>.Default.Equals(Brand, other!.Brand) && EqualityComparer<string>.Default.Equals(Model, other!.Model);
    }
 
    public virtual Car<Clone>$()
    {
		return new Car(this);
    }
 
    protected Car(Car original)
    {
        Brand = original.Brand;
        Model = original.Model;
    }
 
    public void Deconstruct(out string Brand, out string Model)
    {
        Brand = this.Brand;
        Model = this.Model;
    }
}

Positional recordları construct ve deconstruct etmek istersek.

var car = new Car("BMW""2012");
 
var (brand, model) = car;
 
Console.WriteLine($"Model:{model}, Brand:{brand}");

Recordlar ve Inheritance

Recordlarda inheritance desteği de bulunmakta. Inheritance desteğinden bahsederken özellikle değinmemiz gereken kritik iki konu bulunmakta. Ama önce ufak bir inheritance örneği yapalım.

public record Car
{
    public string Model { getinit; }
    public string Brand { getinit; }
}
 
public record SportsCar:Car
{
    public int MaxPower { getset; }
}
Car car = new Car
{
    Brand = "BMW",
    Model = "2012"
};
 
Car sportsCar = new SportsCar
{
    Brand = "BMW",
    Model = "2020",
    MaxPower = 1000
};

with expressionlarıyla beraber yeni bir SportsCar yaratmak istediğimizde compiler arka planda record içerisindeki Clone metodunu kullandığı için otomatik olarak yeni bir sports car nesnesi yaratıp sonra da ilgili değişikliği nesne üzerinde gerçekleştirmekte. Böylece tip bilgisini kaybetmeden yeni nesneyi yaratabiliyor.

Car newSportsCar = sportsCar with { Model = "Ferrari" };

Bu kullanım arka planda şu şekle dönüşmekte.

Car car2 = sportsCar.<Clone>$();
car2.Model = "Ferrari";

Recordların arka planda class olarak tanımlanmasından ziyade aslında value davranışları olduğundan bahsetmiştik. İki farklı recordu karşılaştırırken referans karşılaştırması değil içerisindeki valueların karşılaştırılması yapıldığından tekrardan bahsedelim.

Peki yukarıdaki gibi bir inheritance durumunda eşitlik karşılaştırması nasıl yapılacak diye düşünebiliriz. Yukarıdaki Equals implementasyonundan yola çıkarsak Car recordu Brand ve Model propertylerini karşılaştırırken SportsCar recordu ise bu propertylere ek olarak MaxPower propertysini de karşılaştırıyor. Car recordının perspektifinden baktığımızda recordlar aynı ama SportsCar perspektifinden baktığımızda da recordlar farklı gözükebilir. Ancak bu iki farklı karşılaştırmanın önüne geçmek için bir recorddan türetilen diğer recordlar base içerisinde bulunan virtual protected property olan EqualityContract'ı override etmek zorunda. Bu durumda Equals metodu içerisinde EqualityContract karşılaştırılması da olduğu için iki tarafta aynı şekilde karşılaştırma yapabilmekte.

public class Car : IEquatable<Car>
{
    protected virtual Type EqualityContract
    {
        [System.Runtime.CompilerServices.NullableContext(1)]
        [CompilerGenerated]
        get
        {
            return typeof(Car);
        }
    }
 
    public override int GetHashCode()
    {
        return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Model)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Brand);
    }
 
    public override bool Equals(object? obj)
    {
        return Equals(obj as Car);
    }
 
    public virtual bool Equals(Car? other)
    {
        return (object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<string>.Default.Equals(Model, other!.Model) && EqualityComparer<string>.Default.Equals(Brand, other!.Brand);
    }
}
public class SportsCar : Car, IEquatable<SportsCar>
{
	protected override Type EqualityContract
	{
		[System.Runtime.CompilerServices.NullableContext(1)]
		[CompilerGenerated]
		get
		{
			return typeof(SportsCar);
		}
	}
 
	public override int GetHashCode()
	{
		return base.GetHashCode() * -1521134295 + EqualityComparer<int>.Default.GetHashCode(MaxPower);
	}
 
	public override bool Equals(object? obj)
	{
		return Equals(obj as SportsCar);
	}
 
	public sealed override bool Equals(Car? other)
	{
		return Equals((object?)other);
	}
 
	public virtual bool Equals(SportsCar? other)
	{
		return base.Equals(other) && EqualityComparer<int>.Default.Equals(MaxPower, other!.MaxPower);
	}
}

Yazının başında da değindiğim üzere daha önceden C# içerisinde immutable data ile çalışmayı kolaylaştıracak bazı yenilikler gelse de programlama dili içerisinde tam bir destek söz konusu değildi. Bu versiyonla beraber recordlar dil içerisine eklenmekte ve immutable datayla çalışmak büyük oranda kolaylaşmakta. Hala daha bazı eksikliklerin olduğunu söyleyebiliriz. İlerleyen versiyonlarda bu eksikliklerde mutlaka giderilecektir. C# 9.0 ile beraber gelen işimizi kolaylaştıracak diğer yenilikler de bir sonraki makalenin konusu olacak 😃

Kaynak : https://devblogs.microsoft.com/dotnet/welcome-to-c-9-0/