Niejawna konwersja a klasa typu

94

W Scali możemy użyć co najmniej dwóch metod do modernizacji istniejących lub nowych typów. Załóżmy, że chcemy wyrazić, że coś można określić ilościowo za pomocąInt . Możemy zdefiniować następującą cechę.

Niejawna konwersja

trait Quantifiable{ def quantify: Int }

A potem możemy użyć niejawnych konwersji do kwantyfikacji np. Ciągów znaków i list.

implicit def string2quant(s: String) = new Quantifiable{ 
  def quantify = s.size 
}
implicit def list2quantifiable[A](l: List[A]) = new Quantifiable{ 
  val quantify = l.size 
}

Po ich zaimportowaniu możemy wywołać metodę quantifyna łańcuchach i listach. Zauważ, że wymierna lista przechowuje swoją długość, więc pozwala uniknąć kosztownego przechodzenia przez listę przy kolejnych wywołaniachquantify .

Klasy typu

Alternatywą jest zdefiniowanie „świadka”, Quantified[A]który stwierdza, że ​​pewien typ Amożna określić ilościowo.

trait Quantified[A] { def quantify(a: A): Int }

Następnie udostępniamy instancje tego typu klasy dla Stringi Listgdzieś.

implicit val stringQuantifiable = new Quantified[String] {
  def quantify(s: String) = s.size 
}

A jeśli następnie napiszemy metodę, która wymaga kwantyfikacji swoich argumentów, piszemy:

def sumQuantities[A](as: List[A])(implicit ev: Quantified[A]) = 
  as.map(ev.quantify).sum

Lub używając składni związanej z kontekstem:

def sumQuantities[A: Quantified](as: List[A]) = 
  as.map(implicitly[Quantified[A]].quantify).sum

Ale kiedy użyć której metody?

Teraz pojawia się pytanie. Jak mogę zdecydować między tymi dwoma koncepcjami?

Co zauważyłem do tej pory.

klasy typów

  • klasy typów pozwalają na ładną składnię związaną z kontekstem
  • z klasami typów nie tworzę nowego obiektu opakowującego przy każdym użyciu
  • składnia związana z kontekstem nie działa już, jeśli klasa typu ma wiele parametrów typu; wyobraź sobie, że chcę określić ilościowo rzeczy nie tylko za pomocą liczb całkowitych, ale za pomocą wartości pewnego rodzaju ogólnego T. Chciałbym utworzyć klasę typuQuantified[A,T]

niejawna konwersja

  • ponieważ tworzę nowy obiekt, mogę tam przechowywać wartości lub obliczyć lepszą reprezentację; ale czy powinienem tego unikać, ponieważ może się to zdarzyć kilka razy, a jawna konwersja prawdopodobnie zostanie wywołana tylko raz?

Czego oczekuję od odpowiedzi

Przedstaw jeden (lub więcej) przypadków użycia, w których różnica między oboma pojęciami ma znaczenie i wyjaśnij, dlaczego wolałbym jedno od drugiego. Również wyjaśnienie istoty tych dwóch pojęć i ich wzajemnych relacji byłoby miłe, nawet bez przykładu.

ziggystar
źródło
Istnieje pewne zamieszanie w punktach klas typów, w których wspomina się o „ograniczonym widoku”, chociaż klasy typów używają granic kontekstu.
Daniel C. Sobral
1
+1 doskonałe pytanie; Jestem bardzo zainteresowany dokładną odpowiedzią na to pytanie.
Dan Burton,
@Daniel Dziękuję. Zawsze się mylę.
ziggystar
2
Mylisz się w jednym miejscu: w drugim przykładzie niejawna konwersja ty przechowywać sizewykazu w wartości i powiedzieć, że unika się kosztownych przechodzenie z listy po kolejnych zaproszeń do oszacowania, ale na każdym wywołaniu do zostanie wywołany od nowa, przywracając w ten sposób i ponownie obliczając majątek. Mówię, że w rzeczywistości nie ma sposobu na buforowanie wyników za pomocą niejawnych konwersji. quantifylist2quantifiableQuantifiablequantify
Nikita Volkov
@NikitaVolkov Twoja obserwacja jest słuszna. I odnoszę się do tego w moim pytaniu w przedostatnim akapicie. Buforowanie działa, gdy przekonwertowany obiekt jest używany dłużej po jednym wywołaniu metody konwersji (i być może jest przekazywany w przekonwertowanej postaci). Podczas gdy klasy typów prawdopodobnie zostałyby powiązane łańcuchami wzdłuż nieprzekonwertowanego obiektu podczas wchodzenia głębiej.
ziggystar

Odpowiedzi:

42

Chociaż nie chcę powielać mojego materiału ze Scala In Depth , myślę, że warto zauważyć, że klasy typu / cechy typu są nieskończenie bardziej elastyczne.

def foo[T: TypeClass](t: T) = ...

ma możliwość przeszukiwania lokalnego środowiska w poszukiwaniu domyślnej klasy typu. Jednak w dowolnym momencie mogę zmienić domyślne zachowanie na jeden z dwóch sposobów:

  1. Tworzenie / importowanie niejawnego wystąpienia klasy typu w zakresie w celu niejawnego wyszukiwania zwarciowego
  2. Bezpośrednie przekazanie klasy typu

Oto przykład:

def myMethod(): Unit = {
   // overrides default implicit for Int
   implicit object MyIntFoo extends Foo[Int] { ... }
   foo(5)
   foo(6) // These all use my overridden type class
   foo(7)(new Foo[Int] { ... }) // This one needs a different configuration
}

To sprawia, że ​​klasy typów są nieskończenie bardziej elastyczne. Inną rzeczą jest to, że klasy / cechy typów lepiej obsługują niejawne wyszukiwanie .

W pierwszym przykładzie, jeśli używasz niejawnego widoku, kompilator wykona niejawne wyszukiwanie:

Function1[Int, ?]

Który obejrzy Function1obiekt towarzyszący i Intobiekt towarzyszący.

Zauważ, że nieQuantifiable ma tego nigdzie w niejawnym wyszukiwaniu. Oznacza to, że musisz umieścić niejawny widok w obiekcie pakietu lub zaimportować go do zakresu. Zapamiętywanie tego, co się dzieje, wymaga więcej pracy.

Z drugiej strony klasa typu jest jawna . Widzisz, czego szuka w sygnaturze metody. Masz również niejawne wyszukiwanie

Quantifiable[Int]

który będzie szukał Quantifiableobiektu towarzyszącego i Int obiektu towarzyszącego. Oznacza to, że możesz podać wartości domyślne, a nowe typy (takie jak MyStringklasa) mogą zapewnić wartość domyślną w swoim obiekcie towarzyszącym i zostaną one niejawnie przeszukane.

Generalnie używam klas typów. W pierwszym przykładzie są one nieskończenie bardziej elastyczne. Jedynym miejscem, w którym używam niejawnych konwersji, jest użycie warstwy API między opakowaniem Scala a biblioteką Java, a nawet to może być „niebezpieczne”, jeśli nie jesteś ostrożny.

jsuereth
źródło
20

Jednym z kryteriów, które mogą wejść w grę, jest to, jak chcesz, aby nowa funkcja „czuła się”; używając niejawnych konwersji, możesz sprawić, że będzie wyglądać, jakby to była tylko inna metoda:

"my string".newFeature

... podczas korzystania z klas typów zawsze będzie wyglądać tak, jakbyś wywoływał funkcję zewnętrzną:

newFeature("my string")

Jedną z rzeczy, które można osiągnąć za pomocą klas typów, a nie za pomocą niejawnych konwersji, jest dodanie właściwości do typu , a nie do wystąpienia typu. Następnie możesz uzyskać dostęp do tych właściwości, nawet jeśli nie masz dostępnego wystąpienia tego typu. Przykładem kanonicznym byłoby:

trait Default[T] { def value : T }

implicit object DefaultInt extends Default[Int] {
  def value = 42
}

implicit def listsHaveDefault[T : Default] = new Default[List[T]] {
  def value = implicitly[Default[T]].value :: Nil
}

def default[T : Default] = implicitly[Default[T]].value

scala> default[List[List[Int]]]
resN: List[List[Int]] = List(List(42))

Ten przykład pokazuje również, jak ściśle powiązane są pojęcia: klasy typów nie byłyby tak użyteczne, gdyby nie istniał mechanizm do tworzenia nieskończenie wielu ich instancji; bez implicitmetody (bez konwersji, co prawda) mógłbym mieć tylko skończenie wiele typów posiadających tę Defaultwłasność.

Philippe
źródło
@Phillippe - Jestem bardzo zainteresowany techniką, którą napisałeś ... ale wydaje się, że nie działa w Scali 2.11.6. Wysłałem pytanie z prośbą o zaktualizowanie Twojej odpowiedzi. z góry dzięki, jeśli możesz pomóc: Zobacz: stackoverflow.com/questions/31910923/…
Chris Bedford
@ChrisBedford Dodałem definicję defaultdla przyszłych czytelników.
Philippe
13

Możesz wyobrazić sobie różnicę między tymi dwiema technikami przez analogię do zastosowania funkcji, tylko z nazwanym opakowaniem. Na przykład:

trait Foo1[A] { def foo(a: A): Int }  // analogous to A => Int
trait Foo0    { def foo: Int }        // analogous to Int

Wystąpienie pierwszego zawiera funkcję typu A => Int, podczas gdy wystąpienie drugiego zostało już zastosowane do pliku A. Możesz kontynuować wzór ...

trait Foo2[A, B] { def foo(a: A, b: B): Int } // sort of like A => B => Int

w ten sposób można by pomyśleć o Foo1[B]częściowym zastosowaniu Foo2[A, B]do jakiejś Ainstancji. Doskonały przykład tego został opisany przez Milesa Sabina jako „Zależności funkcjonalne w Scali” .

Tak naprawdę chodzi mi o to, że w zasadzie:

  • „pimowanie” klasy (poprzez niejawną konwersję) jest przypadkiem „zerowego rzędu” ...
  • zadeklarowanie typeklas jest przypadkiem „pierwszego rzędu” ...
  • Typeklasy wieloparametrowe z fundepsami (lub czymś w rodzaju fundeps) to przypadek ogólny.
konflikt połączeń
źródło