Boş değer yerine Belki türleriyle olan diller kenar koşullarını nasıl işler?


53

Eric Lippert, C # 'nın neden nullbir Maybe<T>tip yerine neden kullanıldığını tartışmasında çok ilginç bir noktaya değindi :

Tip sisteminin tutarlılığı önemlidir; Null edilemeyen bir referansın hiçbir zaman geçersiz olduğu gözlemlenen koşullar altında olmadığını her zaman bilebilir miyiz? NULL olmayan bir referans türü olan bir nesnenin yapıcısına ne demeli? Peki ya referansı doldurması gereken kod bir istisna attığı için böyle bir nesnenin sonlandırıcısında, nesnenin sonlandırıldığı yerde? Garantileri konusunda size yalan söyleyen bir tip sistem tehlikelidir.

Bu biraz göz açıcı oldu. İçerdiği kavramlar ilgimi çekiyor ve derleyiciler ve tip sistemleriyle biraz uğraştım ama bu senaryoyu hiç düşünmedim. Sıfır yerine garantili bir boş referansın aslında geçerli bir durumda olmadığı, başlatma ve hata kurtarma gibi boş bir uç kenarı durumları yerine Belki türü olan diller nasıl?


Sanırım Belki dilin bir parçasıysa, dahili olarak boş bir gösterici ile uygulanmış olması ve sadece sözdizimsel bir şeker olması olabilir. Fakat hiçbir dilin böyle yaptığını sanmıyorum.
panzi


1
@RobertHarvey Stack Exchange'de "güzel bir soru" düğmesi yok mu?
kullanıcı253751

2
@panzi Bu güzel ve geçerli bir optimizasyon, ancak bu soruna yardımcı olmuyor: Bir şey a olmadığında Maybe T, olmamalı Noneve dolayısıyla boş göstericiye depolama işlemini başlatamazsınız.

@ immibis: Ben onu çoktan ittim. Burada değerli birkaç güzel soru alıyoruz; Bunun bir yorumu hakettiğini düşündüm.
Robert Harvey,

Yanıtlar:


45

Bu alıntı, tanımlayıcıların beyanı ve atanması (burada: örnek üyeler) birbirinden ayrı olduğunda meydana gelen bir soruna işaret eder . Hızlı bir sözde kod taslağı olarak:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

Senaryo şimdi, bir vakanın inşası sırasında, bir hata atılacak, bu yüzden örnek tamamen inşa edilmeden önce inşaat iptal edilecektir. Bu dil, hafızanın dağıtılmasından önce çalışacak olan yıkıcı bir yöntem sunar, örneğin hafıza dışı kaynakları manuel olarak serbest bırakmak için. Ayrıca kısmen inşa edilmiş nesneler üzerinde çalıştırılmalıdır, çünkü inşaat durdurulmadan önce manuel olarak yönetilen kaynaklar önceden tahsis edilmiş olabilir.

Boş değerlerle, yıkıcı bir değişkene atanmış olup olmadığını test edebilir if (foo != null) foo.cleanup(). Boş değer olmadan, nesne şimdi tanımsız bir durumdadır - değeri barnedir?

Bununla birlikte, bu sorun üç yönün birleşiminden kaynaklanmaktadır :

  • nullÜye değişkenler için varsayılan değerlerin olmaması veya garantili başlatma.
  • Beyanname ile ödev arasındaki fark. Değişkenleri hemen atanmaya zorlamak (örneğin let, işlevsel dillerde görüldüğü gibi bir ifade ile ), garantili bir başlatmayı zorlamak için kolaydı - ancak dili başka şekillerde kısıtlıyordu.
  • Yıkıcıların kendine özgü lezzeti, dil çalışma zamanı tarafından çağrılan bir yöntemdir.

Bu sorunları sergilemeyen başka bir tasarım seçmek kolaydır, örneğin beyanı her zaman atama ile birleştirerek ve dilin tek bir sonlandırma yöntemi yerine birden fazla sonlandırıcı blok sunmasını sağlayarak:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

Öyleyse, boşluğun yokluğunda bir sorun değil, kombinasyonda boş değerin olmadığı bir dizi başka özellik vardır.

Şimdi ilginç soru, neden C #'nın bir tasarım seçtiğini diğeri seçmedi. Burada, teklifin bağlamı C # dilinde boş olan bir çok başka argümanları listeler; bunlar çoğunlukla “aşinalık ve uyumluluk” olarak özetlenebilir - ve bunlar iyi sebeplerdir.


Sonlandırıcının nulls ile başa çıkmasının başka bir nedeni de vardır : referans döngüleri olasılığı nedeniyle sonlandırma sırası garanti edilmez. Ancak, FINALIZEtasarımınızın da şunu çözdüğünü düşünüyorum: fooZaten sonlandırılmışsa, FINALIZEbölümü basitçe çalışmayacaktır.
svick

14

Diğer verileri garanti etmenin aynı yolları geçerli bir durumdadır.

Kişi anlambilgiyi yapılandırabilir ve akışını kontrol edebilir , böylece bunun için tam bir değer yaratmadan bir tür değişkene / alana sahip olamazsınız . Bir nesne oluşturmak ve bir yapıcının alanlarına "başlangıç" değerleri atamasına izin vermek yerine, yalnızca tüm alanları için değerleri bir kerede belirterek bir nesne oluşturabilirsiniz. Bir değişken bildirmek ve ardından bir başlangıç ​​değeri atamak yerine, yalnızca bir başlatmaya sahip bir değişken sunabilirsiniz.

Örneğin, Rust'ta bunu yapan Point { x: 1, y: 2 }bir kurucu yazmak yerine yapı tipi bir nesne yaratırsınız self.x = 1; self.y = 2;. Elbette, bu aklınızdaki dil tarzı ile çakışabilir.

Diğer bir tamamlayıcı yaklaşım, kullanıma başlamadan önce depolamaya erişimi önlemek için canlılık analizi kullanmaktır. Bu, ilk okumadan önce belirlendiği sürece, hemen başlatılmadan bir değişkenin bildirilmesine izin verir. Ayrıca bazı başarısızlıklarla ilgili davaları yakalayabilir.

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

Teknik olarak, nesneler için isteğe bağlı bir varsayılan başlatma da tanımlayabilirsiniz, örneğin tüm sayısal alanları sıfır, dizi alanları için boş diziler oluşturabilirsiniz, ancak bu oldukça isteğe bağlıdır, diğer seçeneklerden daha az verimlidir ve hataları maskeleyebilir.


7

İşte Haskell nasıl yapıyor: (Haskell Nesneye Yönelik bir dil olmadığından Lippert'in ifadelerine tam olarak uymaz).

UYARI: Önümüzdeki ciddi bir Haskell fanatiğinden uzun soluklu cevap.

TL; DR

Bu örnek Haskell'in C # 'dan ne kadar farklı olduğunu göstermektedir. Yapı inşaatının lojistiğini bir kurucuya devretmek yerine, çevre kodunda ele alınmalıdır. Bir boş (veya NothingHaskell'de) değer değerinin boş olmayan bir değer beklediğimiz yerde kesmesine imkan yoktur, çünkü boş değerler yalnızca Maybedoğrudan, doğrudan normalden dönüştürülebilen ve normalden dönüştürülemeyen özel sargılı türlerde oluşabilir. null türleri N'ye sarılarak null yapılabilen bir değeri kullanmak için Maybe, önce kontrol eşlemesini boş olmayan bir değere sahip olduğumuzu bildiğimiz bir dallamaya yönlendirmeye zorlayan, kalıp eşleştirme kullanarak değeri çıkarmamız gerekir.

Bu nedenle:

Null edilemeyen bir referansın hiçbir zaman geçersiz olduğu gözlemlenen koşullar altında olmadığını her zaman bilebilir miyiz?

Evet. Intve Maybe Intiki tamamen ayrı tipler. Bulma Nothingbir ovada Intbir dize "balık" bulma karşılaştırılabilir olacaktır Int32.

NULL olmayan bir referans türü olan bir nesnenin yapıcısına ne demeli?

Sorun değil: Haskell'deki değer yapıcıları hiçbir şey yapamazlar, verilen değerleri alıp bir araya getirirler. Tüm başlatma mantığı, yapıcı çağrılmadan önce gerçekleşir.

Peki ya referansı doldurması gereken kod bir istisna attığı için böyle bir nesnenin sonlandırıcısında, nesnenin sonlandırıldığı yerde?

Haskell'de kesinleştiriciler yok, bu yüzden gerçekten buna değinemiyorum. Ancak ilk cevabım hala geçerli.

Tam Cevap :

Haskell'in boş değeri yoktur ve boş Maybealanları temsil etmek için veri türünü kullanır . Belki de bunun gibi tanımlanmış bir cebirsel veri türüdür :

data Maybe a = Just a | Nothing

Haskell'ı tanımayanlar için bunu "A Maybeya a Nothingya da a Just a" olarak okuyunuz . özellikle:

  • Maybeolduğu bir tipte : (bir genel sınıf olarak (yanlış) düşünülebilir atipi değişkendir). C # analojisidir class Maybe<a>{}.
  • Justbir değer yapıcısı : bir tür argümanı alan ave değeri Maybe aiçeren bir tür değeri döndüren bir işlevdir . Yani kod x = Just 17benzer int? x = 17;.
  • Nothingbaşka bir değer kurucu, ancak hiçbir argüman almaz ve Maybegeri dönen, "Hiçbirşey" den başka bir değere sahip değildir. x = Nothingbenzerdir int? x = null;(biz bizim kısıtlı varsayarak aHaskell olmak Intyazılı yapılabilir, hangi x = Nothing :: Maybe Int).

Şimdi, Maybetürün temelleri ortadan kalktığına göre Haskell, OP'nin sorusunda tartışılan sorunları nasıl önler?

Haskell, şu ana kadar tartışılan dillerin çoğundan gerçekten farklı, bu yüzden birkaç temel dil ilkesini açıklayarak başlayacağım.

Öncelikle, Haskell'de her şey değişmez . Her şey. Adlar, değerlerin saklanabileceği yerlere değil, değerlere atıfta bulunur (bu yalnızca büyük bir hata giderme kaynağıdır). Değişken bildirim ve atama değerlerini tanımlayarak oluşturulan Haskell değerlerinde iki ayrı operasyon vardır C #, aksine (örneğin x = 15, y = "quux", z = Nothing), asla değiştiremezsin ki. Bu nedenle, kod gibi:

ReferenceType x;

Haskell'de mümkün değil. Değerlerin başlatılması ile ilgili hiçbir sorun yoktur, nullçünkü her şeyin var olması için açıkça bir değere başlatılması gerekir.

İkincisi, Haskell nesne yönelimli bir dil değildir : tamamen işlevsel bir dildir, dolayısıyla kelimenin tam anlamıyla hiçbir nesne yoktur. Bunun yerine, argümanlarını alan ve birleştirilen bir yapı döndüren işlevler (değer yapıcıları) vardır.

Sonra, kesinlikle bir zorunlu stil kodu yoktur. Bununla, çoğu dilin böyle bir örüntü izlediğini kastediyorum:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

Program davranışı bir dizi talimat olarak ifade edilir. Nesne Yönelimli dillerde, sınıf ve işlev bildirimleri de program akışında büyük rol oynar, ancak esastır, bir programın yürütülmesinin "eti" yürütülmesi gereken bir dizi talimat biçimini alır.

Haskell'de bu mümkün değildir. Bunun yerine, program akışı tamamen zincirleme işlevleriyle belirlenir. Emir dokuvveti arayan gösterim bile isimsiz işlevleri >>=operatöre aktarmak için sadece sözdizimsel bir şeker . Tüm fonksiyonlar şu şekildedir:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

body-expressionBir değeri değerlendiren herhangi bir şey nerede olabilir. Açıkçası daha fazla sözdizimi özellikleri mevcut ancak ana nokta, ifadelerin dizisinin tam olmamasıdır.

Son olarak, ve muhtemelen en önemlisi, Haskell'in tür sistemi inanılmaz derecede katıdır. Haskell'in tip sisteminin merkezi tasarım felsefesini özetlemek zorunda olsaydım derdim: "Derleme zamanında mümkün olduğunca çok şeyin yanlış gitmesini sağlayın, böylece çalışma zamanında olabildiğince az yanlış yapın." Hiçbir örtük dönüşüm (bir tanıtmak isteyen var Inta Double? Kullanın fromIntegralfonksiyonu). Çalışma zamanında meydana gelen geçersiz bir değere sahip olan tek şey kullanmaktır Prelude.undefined(görünüşe göre sadece orada olması ve kaldırılması imkansızdır ).

Bütün bunlar göz önünde bulundurularak, Amon'un "kırık" örneğine bakalım ve bu kodu Haskell'de tekrar ifade etmeye çalışalım. İlk olarak, veri bildirimi (adlandırılmış alanlar için kayıt sözdizimini kullanarak):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( foove bargerçekten asıl alanlar yerine anonim alanlara erişim işlevi görür, ancak bu ayrıntıyı yok sayabiliriz).

NotSoBrokenDeğeri yapıcı bir alma dışında herhangi bir işlem alma yeteneğinde olmayan Foove Bar(null değildir) ve bir alma NotSoBrokenbunların out. Zorunlu kod koymak veya hatta alanları manuel olarak atamak için yer yoktur. Tüm başlatma mantığı, başka bir yerde, büyük olasılıkla özel bir fabrika fonksiyonunda gerçekleşmelidir.

Örnekte, Brokenher zaman yapılaşma başarısız olur. NotSoBrokenDeğer yapıcısını benzer bir şekilde kırmanın bir yolu yoktur (kodu yazacak hiçbir yer yoktur), ancak benzer şekilde hatalı olan bir fabrika işlevi oluşturabiliriz.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(ilk satır bir tür imza bildirimidir: makeNotSoBrokena Foove Bara'yı argüman olarak alır ve a üretir Maybe NotSoBroken).

Geri dönüş tipi, Maybe NotSoBrokenbasitçe NotSoBrokendeğerlendirmek zorunda olduğumuz için değil Nothing, bunun için bir değer yapıcısı olduğu söylenmelidir Maybe. Farklı bir şey yazsaydık, türler sıraya girmezdi.

Kesinlikle anlamsız olmasının yanı sıra, bu işlev ne zaman kullanacağımızı göreceğimiz gibi asıl amacını bile yerine getirmiyor. Bir argüman olarak useNotSoBrokenbekleyen bir fonksiyon yaratalım NotSoBroken:

useNotSoBroken :: NotSoBroken -> Whatever

( bir argümanı useNotSoBrokenkabul eder NotSoBrokenve üretir Whatever).

Ve bu şekilde kullanın:

useNotSoBroken (makeNotSoBroken)

Çoğu dilde, bu tür davranışlar boş gösterici istisnalarına neden olabilir. Haskell'de türler eşleşmiyor: a makeNotSoBrokendöndürür Maybe NotSoBroken, ancak a useNotSoBrokenbekler NotSoBroken. Bu türler birbirlerinin yerine geçemez ve kod derlenemez.

Bunu aşmak caseiçin Maybedeğerin yapısına dayanarak dallara yapılan bir ifade kullanabiliriz ( desen eşleme adı verilen bir özelliği kullanarak ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

Açıkçası, bu pasajın aslında derlemek için bir bağlamın içine yerleştirilmesi gerekiyor, ancak Haskell'in nullaları nasıl idare ettiğinin temellerini gösteriyor. Yukarıdaki kodun adım adım açıklaması:

  • Birincisi, makeNotSoBrokenbir tür değer üretmesi garanti edilen değerlendirilir Maybe NotSoBroken.
  • caseİfadesi bu değerin yapısını inceler.
  • Değer ise Nothing, "burada durum ele" kodu değerlendirilir.
  • Değer, bunun yerine bir Justdeğerle eşleşirse, diğer dal yürütülür. Eşleşen maddenin eşzamanlı olarak değeri bir Justyapı olarak nasıl tanımladığını ve iç NotSoBrokenalanını bir isme nasıl bağladığını not edin (bu durumda, x). xdaha sonra normal NotSoBrokendeğer gibi kullanılabilir .

Bu nedenle, desen eşleştirme, tür güvenliğini sağlamak için güçlü bir olanak sağlar, çünkü nesnenin yapısı kontrolün dallanmasına ayrılmaz bir şekilde bağlıdır.

Umarım bu anlaşılabilir bir açıklama olmuştur. Eğer mantıklı değilse, Büyük İyilik İçin Size Bir Haskell Öğrenin! , şimdiye kadar okuduğum en iyi çevrimiçi dil öğreticilerinden biri. İnşallah aynı güzelliği yaptığım bu dilde göreceksiniz.


TL; DR en üstte olmalı :)
andrew.fox 8:16

@ andrew.fox İyi nokta. Düzenleyeceğim.
Yaklaşan

0

Bence teklifin çok sert bir adam argümanı.

Günümüzde modern diller (C # dahil), yapıcının tamamen tamamlandığını veya tamamlanmadığını garanti eder.

Yapıcıda bir istisna varsa ve nesne kısmen başlatılmamış olarak bırakılırsa, başlatılmamış duruma sahip olmak nullveya Maybe::nonebaşlatılmamış durum için yıkıcı kodda gerçek bir fark yoktur.

Her iki şekilde de başa çıkmak zorunda kalacaksın. Yönetilecek dış kaynaklar olduğunda, bunları açıkça herhangi bir şekilde yönetmelisiniz. Diller ve kütüphaneler yardımcı olabilir, ancak buna biraz düşünmeniz gerekecek.

BTW: C #, nulldeğer hemen hemen eşittir Maybe::none. nullYalnızca tür düzeyinde null olarak nitelendirilen değişkenlere ve nesne üyelerine atayabilirsiniz :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Bu, aşağıdaki snippet'ten hiçbir şekilde farklı değildir:

Maybe<String> optionalString = getOptionalString();

Sonuç olarak, null'un ne tür bir zıtlıkta olduğunu göremiyorum Maybe. Hatta C # 'nın kendi Maybetüründe gizlice girdiğini ve onu çağırdığını bile söyleyebilirim Nullable<T>.

Uzatma yöntemleri ile, monadik modeli takip etmek için Nullable'ın temizlenmesi bile kolaydır :

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
"kurucu ya tamamen tamamlandı ya da bitmedi" ne demek? Örneğin, Java'da, yapıcıdaki (son olmayan) alanın başlatılması veri yarışından korunmaz - bu tam olarak tamamlandı mı veya tamamlamaz mı?
tatarcık

@gnat: "Java'da, örneğin (final olmayan) alanın yapıcıda başlatılması veri yarışından korunmuyor" derken neyi kastediyorsunuz? Birden fazla iplik içeren olağanüstü karmaşık bir şey yapmazsanız, bir yapıcı içindeki yarış koşullarının olasılığı neredeyse imkansızdır (ya da olması gerekir). Nesne yapıcısının içinden başka bir nesnenin alanına erişemezsiniz. Ve inşaat başarısız olursa, nesneye bir referansınız yoktur.
Roland Tepp,

nullHer türün örtük üyesi olarak bunun arasındaki büyük fark Maybe<T>, bunun için geçerli olan Maybe<T>, sadece Therhangi bir varsayılan değeri olmayan da olabilir.
svick

Diziler oluştururken, bazı unsurları okumak zorunda kalmadan tüm elemanlar için faydalı değerleri belirlemek mümkün olmayacak ve faydalı bir değer hesaplanmadan hiçbir elemanın okunmadığını statik olarak doğrulamak mümkün olmayacaktır. Yapılabilecek en iyi şey, dizi öğelerini kullanılamaz olarak algılanabilecek şekilde başlatmaktır.
supercat

@svick: C # 'da (OP tarafından söz konusu olan dil), nullher türden örtük bir üye değildir. İçin nulllebal değer olarak, bir hale açıkça null olması türü tanımlamak gerekir T?(sözdizimi şeker için Nullable<T>) büyük ölçüde denk Maybe<T>.
Roland Tepp

-3

C ++, yapıcı gövdesinden önce oluşan başlatıcıya erişerek yapar. C # varsayılan başlatıcıyı kurucu gövdeden önce çalıştırır, kabaca her şeye 0 atar, floats0.0 olur, boolsyanlış olur, referanslar boş kalır, vb. C ++ 'da boş olmayan bir referans tipinin asla boş olmamasını sağlamak için farklı bir başlatıcı çalıştırmasını sağlayabilirsiniz. .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
soru Belki türleri olan diller hakkındaydı
gnat

3
Referanslar boş hale gelir ” - sorunun tamamı bizim sahip nullolmadığımızdır ve bir değerin yokluğunu göstermenin tek yolu , AFAIK C ++ 'nın sahip olmadığı bir Maybetip kullanmaktır (olarak da bilinir Option) standart kütüphane Boş değerlerin olmaması, bir alanın her zaman tip sisteminin bir özelliği olarak geçerli olacağını garanti etmemizi sağlar . Bu, bir değişkenin hala olabileceği yerlerde hiçbir kod yolunun bulunmadığından emin olmaktan daha güçlü bir garantidir null.
amon

C ++ doğal olarak Belki türlerine açıkça sahip olmasa da, std :: shared_ptr <T> gibi şeyler, yeterince açıktır, c ++ 'ın değişkenlerin başlatılmasının kurucunun "kapsam dışı" olabileceği durumuyla ilgilenmesi hala geçerli olduğunu düşünüyorum. aslında referans türleri için (&) gereklidir, çünkü bunlar boş olamazlar.
FryGuy
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.