C#’da Akıcı Jenerikler • Oleksii Holub

In: Genel


Genel programlama, statik olarak yazılmış birçok dilde bulunan güçlü bir özelliktir. Türlerin kendileri yerine paylaştıkları özellikleri hedefleyerek, birçok farklı türe karşı sorunsuz bir şekilde çalışan kod yazmanın bir yolunu sunar. Bu, tip güvenliğinden ödün vermeden veya gereksiz tekrarlar oluşturmadan esnek ve yeniden kullanılabilir bileşenler oluşturmak için araçlar sağlar.

Jenerik ilaçlar bir süredir C#’da olsa da, bazen onları kullanmanın yeni ve ilginç yollarını bulmayı başarabiliyorum. Örneğin, benim bir önceki makaleler Geri dönüş türü çıkarımı elde etmeye yardımcı olan ve konteyner birliği türleriyle çalışmanın daha kolay bir yolunu sağlayan bulduğum bir numara hakkında yazdım.

Son zamanlarda, jenerikleri içeren bazı kodlar üzerinde de çalışıyordum ve olağandışı bir zorlukla karşılaştım: Tüm tür argümanlarının isteğe bağlı olduğu, ancak birbirleriyle rastgele kombinasyonlarda kullanılabilecek bir imza tanımlamam gerekiyordu. Başlangıçta, aşırı yüklemeler ekleyerek bunu yapmaya çalıştım, ancak bu, pek sevmediğim, pratik olmayan bir tasarıma yol açtı.

Biraz denemeden sonra, şuna benzer bir yaklaşım kullanarak bu sorunu zarif bir şekilde çözmenin bir yolunu buldum. akıcı arayüz tasarım deseni, nesneler yerine türlere göre uygulanması dışında. Ulaştığım tasarım, tüketicilerin ihtiyaç duydukları türü bir dizi mantıksal adımda “yapılandırarak” çözmelerine olanak tanıyan alana özgü bir dil içeriyor.

Bu makalede, bu yaklaşımın ne hakkında olduğunu ve karmaşık genel türleri daha erişilebilir bir şekilde düzenlemek için nasıl kullanabileceğinizi açıklayacağım.

akıcı arayüzler

Nesne yönelimli programlamada, akıcı arayüz tasarım, esnek ve kullanışlı arayüzler oluşturmak için popüler bir kalıptır. Temel fikri, insan tarafından okunabilen talimatların sürekli akışı yoluyla etkileşimleri ifade etmek için yöntem zincirlemeyi kullanma etrafında döner.

Diğer şeylerin yanı sıra, bu model, büyük (potansiyel olarak isteğe bağlı) giriş parametreleri setlerine dayanan işlemleri basitleştirmek için yaygın olarak kullanılır. Tüm girdileri önceden beklemek yerine, akıcı bir şekilde tasarlanan arayüzler, ilgili yönlerin her birinin birbirinden ayrı olarak yapılandırılması için bir yol sağlar.

Örnek olarak aşağıdaki kodu ele alalım:

var result = RunCommand(
    "git", // executable (required)
    "pull", // args (optional)
    "/my/repository", // working dir (optional)
    new Dictionary<string, string> // env vars (optional)
    {
        ["GIT_AUTHOR_NAME"] = "John",
        ["GIT_AUTHOR_EMAIL"] = "john@email.com"
    }
);

Bu snippet’te, RunCommand bir alt süreç oluşturma ve tamamlanana kadar engelleme yöntemi. Komut satırı bağımsız değişkenleri, çalışma dizini ve ortam değişkenleri gibi ilgili ayarlar, giriş parametreleri aracılığıyla belirtilir.

Tamamen işlevsel olmasına rağmen, yukarıdaki yöntem çağırma ifadesi insan tarafından çok okunabilir değildir. Bir bakışta, kod yorumlarına güvenmeden her bir parametrenin ne yaptığını söylemek bile zor.

Ek olarak, parametrelerin çoğu isteğe bağlı olduğundan, yöntem tanımının da bunu hesaba katması gerekir. Aşırı yüklemeler, varsayılan değerlere sahip adlandırılmış parametreler vb. dahil olmak üzere bunu başarmanın farklı yolları vardır, ancak bunların tümü oldukça karmaşıktır ve yetersiz deneyim sunar.

Ancak, yöntemi akıcı bir arayüze dönüştürerek bu tasarımı iyileştirebiliriz:

var result = new Command("git")
    .WithArguments("pull")
    .WithWorkingDirectory("/my/repository")
    .WithEnvironmentVariable("GIT_AUTHOR_NAME", "John")
    .WithEnvironmentVariable("GIT_AUTHOR_EMAIL", "john@email.com")
    .Run();

Bu yaklaşımla, tüketici bir durum bilgisi oluşturabilir. Command Gerekli yürütülebilir dosya adını belirterek nesne. Ortaya çıkan ifade yalnızca önemli ölçüde daha okunabilir olmakla kalmaz, aynı zamanda yöntem parametrelerinin doğal sınırlamaları tarafından kısıtlanmadığından çok daha esnektir.

Akıcı tip tanımları

Bu noktada, jeneriklerle ilgili herhangi birinin nasıl olduğunu merak ediyor olabilirsiniz. Ne de olsa bunlar sadece işlevler ve bunun yerine tip sisteminden bahsetmemiz gerekiyor.

Eh, bağlantı şu gerçeğinde yatmaktadır: türler dışında jenerikler de sadece işlevlerdir. Aslında, genel bir türü, gerekli genel bağımsız değişkenleri sağladıktan sonra normal bir türe çözümlenen özel bir üst düzey yapı olarak düşünebilirsiniz. Bu, somut bir değere çözümlemek için bir işleve karşılık gelen argümanlarla sağlanması gereken işlevler ve değerler arasındaki ilişkiye benzer.

Benzerlikleri nedeniyle, genel türler de bazen aynı tasarım sorunlarından muzdarip olabilir. Bunu göstermek için, bir web çerçevesi oluşturduğumuzu ve bir web çerçevesi tanımlamak istediğimizi düşünelim. Endpoint Seri durumdan çıkarılmış istekleri karşılık gelen yanıt nesnelerine eşlemekten sorumlu arabirim.

Böyle bir tip aşağıdaki imza kullanılarak modellenebilir:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    // This method gets called by the framework
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

Burada, alması amaçlanan isteğe karşılık gelen bir tür argümanı alan temel bir genel sınıf ve sağlaması beklenen yanıt biçimini belirten başka bir tür argümanı var. Bu sınıf ayrıca şunları tanımlar: ExecuteAsync belirli bir uç noktayla ilgili mantığı uygulamak için kullanıcının geçersiz kılması gereken yöntem.

Bunu, rota işleyicilerimizi şu şekilde oluşturmak için temel olarak kullanabiliriz:

public class SignInRequest
{
    public string Username { get; init; }
    public string Password { get; init; }
}

public class SignInResponse
{
    public string Token { get; init; }
}

public class SignInEndpoint : Endpoint<SignInRequest, SignInResponse>
{
    [HttpPost("auth/signin")]
    public override async Task<ActionResult<SignInResponse>> ExecuteAsync(
        SignInRequest request,
        CancellationToken cancellationToken = default)
    {
        var user = await Database.GetUserAsync(request.Username);

        if (!user.CheckPassword(request.Password))
        {
            return Unauthorized();
        }

        return Ok(new SignInResponse
        {
            Token = user.GenerateToken()
        });
    }
}

miras alarak Endpoint<SignInRequest, SignInResponse>, derleyici, giriş noktası yönteminde doğru imzayı otomatik olarak zorlar. Bu, olası hataların önlenmesine yardımcı olduğu ve uygulamanın yapısını daha tutarlı hale getirdiği için çok uygundur.

Bununla birlikte, her ne kadar SignInEndpoint bu tasarıma mükemmel uyum sağlar, tüm uç noktaların mutlaka bir isteği ve yanıtı olması gerekmez. Örneğin, bir analog SignUpEndpoint büyük olasılıkla sadece bir durum kodu döndürecek ve bir yanıt gövdesi içermeyecek, ancak SignOutEndpoint özel bir talebe bile ihtiyaç duymaz.

Bunun gibi uç noktaları uygun şekilde yerleştirmek için, birkaç ek genel tür aşırı yüklemesi ekleyerek modelimizi genişletmeyi deneyebiliriz:

// Endpoint that expects a typed request and provides a typed response
public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

// Endpoint that expects a typed request but does not provide a typed response (*)
public abstract class Endpoint<TReq> : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

// Endpoint that does not expect a typed request but provides a typed response (*)
public abstract class Endpoint<TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

// Endpoint that neither expects a typed request nor provides a typed response
public abstract class Endpoint : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

Bir bakışta, bu sorunu çözmüş gibi görünebilir, ancak yukarıdaki kod aslında derlenmiyor. Bunun nedeni, gerçek şu ki, Endpoint<TReq> ve Endpoint<TRes> belirsizdir, çünkü tek bir sınırlandırılmamış tür bağımsız değişkeninin bir istek mi yoksa bir yanıt mı belirtmesi gerektiğini belirlemenin bir yolu yoktur.

ile olduğu gibi RunCommand makalenin önceki bölümlerinde yer alan yöntem, bu sorunu çözmenin birkaç basit yolu vardır, ancak bunlar özellikle zarif değildir. Örneğin, en basit çözüm, türlerin yeteneklerini adlarına yansıtacak ve süreçteki çakışmaları önleyecek şekilde yeniden adlandırmak olacaktır:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class EndpointWithoutResponse<TReq> : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class EndpointWithoutRequest<TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

public abstract class Endpoint : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

Bu, sorunu giderir, ancak oldukça çirkin bir tasarımla sonuçlanır. Türlerin yarısı farklı adlandırıldığından, kitaplığın kullanıcısı onları bulmakta ve hatta ilk etapta varlıklarını bilmekte zorlanabilir. Ayrıca, gelecekte daha fazla değişken eklemek isteyebileceğimizi düşünürsek (örneğin, async’e ek olarak async olmayan işleyiciler), bu yaklaşımın çok iyi ölçeklenmediği de açıkça ortaya çıkıyor.

Tabii ki, yukarıdaki tüm sorunlar biraz yapmacık görünebilir ve bunları çözmeye çalışmak için hiçbir neden olmayabilir. Ancak, kişisel olarak geliştirici deneyimini optimize etmenin kitaplık kodu yazmanın son derece önemli bir yönü olduğuna inanıyorum.

Neyse ki, kullanabileceğimiz daha iyi bir çözüm var. İşlevler ve genel türler arasındaki paralellikleri çizerek, tür aşırı yüklerimizden kurtulabilir ve bunları akıcı bir şema ile değiştirebiliriz:

public static class Endpoint
{
    public static class WithRequest<TReq>
    {
        public abstract class WithResponse<TRes>
        {
            public abstract Task<ActionResult<TRes>> ExecuteAsync(
                TReq request,
                CancellationToken cancellationToken = default
            );
        }

        public abstract class WithoutResponse
        {
            public abstract Task<ActionResult> ExecuteAsync(
                TReq request,
                CancellationToken cancellationToken = default
            );
        }
    }

    public static class WithoutRequest
    {
        public abstract class WithResponse<TRes>
        {
            public abstract Task<ActionResult<TRes>> ExecuteAsync(
                CancellationToken cancellationToken = default
            );
        }

        public abstract class WithoutResponse
        {
            public abstract Task<ActionResult> ExecuteAsync(
                CancellationToken cancellationToken = default
            );
        }
    }
}

Yukarıdaki tasarım, daha önceki orijinal dört türü korur, ancak bunları düz bir yapı yerine hiyerarşik bir yapıda düzenler. Bunu başarmak mümkündür çünkü C#, tür tanımlarının jenerik olsalar bile iç içe geçmesine izin verir.

Aslında, Jeneriklerde bulunan türler özeldir çünkü üstlerinde belirtilen tür bağımsız değişkenlerine de erişim sağlarlar.. koymamızı sağlar WithResponse<TRes> içeri WithRequest<TReq> ve ikisini de kullan TReq ve TRes içini tanımlamak ExecuteAsync yöntem.

İşlevsel olarak, yukarıda gösterilen yaklaşım ve önceki yaklaşım aynıdır. Bununla birlikte, burada kullanılan geleneksel olmayan yapı, aynı düzeyde esneklik sunarken, keşfedilebilirlik sorunlarını tamamen ortadan kaldırır.

Şimdi, kullanıcı bir uç nokta uygulamak isterse, bunu şu şekilde yapabilirdi:

public class MyEndpoint
    : Endpoint.WithRequest<SomeRequest>.WithResponse<SomeResponse> { /* ... */ }

public class MyEndpointWithoutResponse
    : Endpoint.WithRequest<SomeRequest>.WithoutResponse { /* ... */ }

public class MyEndpointWithoutRequest
    : Endpoint.WithoutRequest.WithResponse<SomeResponse> { /* ... */ }

public class MyEndpointWithoutNeither
    : Endpoint.WithoutRequest.WithoutResponse { /* ... */ }

Ve işte nasıl güncellendi SignInEndpoint Benzeyecekmiş gibi:

public class SignInEndpoint : Endpoint
    .WithRequest<SignInRequest>
    .WithResponse<SignInResponse>
{
    [HttpPost("auth/signin")]
    public override async Task<ActionResult<SignInResponse>> ExecuteAsync(
        SignInRequest request,
        CancellationToken cancellationToken = default)
    {
        // ...
    }
}

Gördüğünüz gibi, bu yaklaşım çok etkileyici ve temiz tip bir imzaya yol açar. Kullanıcının ne tür bir uç nokta uygulamak istediğine bakılmaksızın, her zaman Endpoint sınıflandırın ve ihtiyaç duydukları yetenekleri akıcı ve insan tarafından okunabilir bir şekilde oluşturun.

Bunun yanı sıra, tip yapımız esasen sonlu durumlu bir makineyi temsil ettiğinden, yanlışlıkla yanlış kullanıma karşı güvenlidir. Örneğin, bir uç nokta oluşturmaya yönelik aşağıdaki yanlış girişimlerin tümü derleme zamanı hatalarına neden olur:

// Incomplete signature
// Error: Class Endpoint is sealed
public class MyEndpoint : Endpoint { /* ... */ }

// Incomplete signature
// Error: Class Endpoint.WithRequest<TReq> is sealed
public class MyEndpoint : Endpoint.WithRequest<MyRequest> { /* ... */ }

// Invalid signature
// Error: Class Endpoint.WithoutRequest.WithRequest<T> does not exist
public class MyEndpoint : Endpoint.WithoutRequest.WithRequest<MyRequest> { /* ... */ }

Özet

Genel türler inanılmaz derecede faydalı olsa da, katı yapıları bazı senaryolarda tüketilmelerini zorlaştırabilir. Özellikle, birden çok farklı tür argümanı kombinasyonunu kapsayan bir imza tanımlamamız gerektiğinde, aşırı yüklemeye başvurabiliriz, ancak bu belirli sınırlamalar getirir.

Alternatif bir çözüm olarak, jenerik türleri iç içe yerleştirebilir ve kullanıcıların bunları akıcı bir şekilde oluşturmasına olanak tanıyan hiyerarşik bir yapı oluşturabiliriz. Bu, optimal kullanılabilirliği korurken çok daha fazla kişiselleştirme elde etmek için araçlar sağlar.

Bir cevap yazın

Ready to Grow Your Business?

We Serve our Clients’ Best Interests with the Best Marketing Solutions. Find out More

How Can We Help You?

Need to bounce off ideas for an upcoming project or digital campaign? Looking to transform your business with the implementation of full potential digital marketing?

For any career inquiries, please visit our careers page here.
[contact-form-7 404 "Bulunamadı"]