Grzegorz Stala

Senior Software Engineer

Trener personalny

Grzegorz Stala

Senior Software Engineer

Trener personalny

Post

Trzymaj swój porządek, głupcze!

2024-02-04 Bez kategorii

Czy zastanawialiście się kiedyś, w jaki sposób kolekcje w Scali traktują swój wewnętrzny porządek? Przyznam się bez bicia – powyższy problem nigdy nie spędzał mi snu z powiek (delikatnie mówiąc). Co posortowane, to posortowane. Nieważne czy to zbiór, czy lista, prawda?

No nie za bardzo.

Jakiś czas temu miałem do czynienia z bardzo ciekawym przypadkiem, który ominął nawet całkiem sensowne sito testów. Diabeł jednak jak zwykle tkwi w szczegółach. A tych “szczegółów” w przypadku Scali jest całkiem sporo (co notabene jest kołem zamachowym przeciwników tego języka). Do rzeczy jednak, spójrz na poniższy model:

case class Student(id: String, firstName: String, lastName: String, age: Int, avgGrades: Double)
case class Course(name: String, enrolledStudents: Set[Student], reserveList: Set[Student])

Mamy klasę odzwierciedlającą studenta oraz kurs, na który może być on zapisany bądź trafić na listę rezerwową. Spróbujmy teraz zaimplementować następujący kontrakt:

def createBasedOnGrade(name: String, students: List[Student], maxPositions: Int): Course

Chcemy mieć możliwość utworzenia nowego kursu z limitem miejsc (maxPositions), gdzie o zapisie bądź trafieniu na listę rezerwową zadecyduje średnia ocen (avgGrades). Takie zachowanie możemy osiągnać w Scali bardzo prosto za pomocą funkcji sortBy oraz splitAt (uwaga, sortujemy malejąco!)

val (eligible, notEligible) = students
.sortBy(_.avgGrades)(Ordering[Double].reverse)
.splitAt(maxPositions)
Course(name, eligible, notEligible)

Kod jest poprawny, z dokładnością do typów, co kompilator dosyć szybko nad podpowie. Kolekcje w Course to Set’y, a my operujemy na listach. Da się to jednak łatwo naprawić, jak jednak będzie najładniej?

Course(name, eligible.toSet, notEligible.toSet)

Troche brzydkie, spróbujmy wykonać konwersje do zbioru nieco wyżej

val (eligible, notEligible) = students
.toSet
.sortBy(_.avgGrades)(Ordering[Double].reverse)

Nie przejdzie, przecież na zbiorze nie zrobimy toSet, głupi ja!

val (eligible, notEligible) = students
.sortBy(_.avgGrades)(Ordering[Double].reverse)
.toSet
.splitAt(maxPositions)

No, teraz jest elegancko. Kodziaszek wygląda ładnie i wydaje się, że spełnia nasze wymagania. “Wydaje się”, to jednak za mało jeśli jest się szanującym się inżynierem. Trzeba napisać test.

it should “create a course with enrolled and reserve list” in {
  val students = List(
  Student(“id1”, “John”, “Newman”, 20, 4.3),
  Student(“id2”, “Mark”, “James”, 21, 4.8),
  Student(“id3”, “Olivia”, “Spencer”, 23, 4.75),
  Student(“id4”, “Natalie”, “Smith”, 19, 4.2)
  )
  val course = Course.createBasedOnGrade(“Mathematical analysis”, students, maxPositions = 2)
  course.enrolledStudents.map(_.id) should contain only (“id2”, “id3”)
  course.reserveList.map(_.id) should contain only (“id1”, “id4”)
}

Test już chyba nie pozostawia złudzeń. Odsialiśmy “słabych” studenciaków, a najlepsi mogą zostać zapisani na upgragniony kurs. Założmy, że wychodzimy z takim kodem na produkcję. Pewnie nie minęłaby godzina, a już w dziekanacie rozdzwoniłyby się telefony z pretensjami od wkurzonych żaków. “Jak to możliwe, że ta głupia Anka dostała się na kurs, a ja nie. Przecież mam wyższą średnią!”

Jak wspomniałem na początku tego tekstu, diabeł tkwi w szczegółach. Szczegóły w tym przypadku są dwa. Pierwszy z nich, to umiejscowienie konwersji toSet, która następuje po sortowaniu, a przed podziałem na studentów w oparciu o średnią. Konwersja ta nie tworzy zbioru posortowanego, tracimy więc informację, dostępną w poprzednim kroku. Dlaczego jednak test nie jest w stanie tego wykryć. Czy mieliśmy “farta”, i tworząc zbiór na podstawie listy otrzymaliśmy żądaną przez nas kolejność?

Rozwiązanie jest jeszcze bardziej subtelne, co pięknie pokazuje nam debugger.

W przypadku pierwszego testu, nasz zbior legitymizuje się typem Set$Set4, w drugim zaś jest to już nieco bardziej znany HashSet. Czymże jest ten tajemniczy Set4? Ano jest to jedna z wielu optymalizacji, jaką niesie ze sobą sam język – uproszczona implementacja zbioru, gdzie jego elementy nie są niczym innym, jak tylko polami klasy:

final class Set4[A] private[collection] (elem1: A, elem2: A, elem3: A, elem4: A)

W Scali istnieją uproszczone typy dla zbiorów: jedno, dwu, trzy i czteroelementowego. W chwili tworzenia takiego zbioru elementy nie trafiają na przypadkowe miejsca, lecz są przypisywane kolejno do pól. Aby wykazać błąd w teście, wystarczy zwiększyć listę studentów w teście z czterech do pięciu.

Zagadka rozwiązana. Można się rozejść.

Taggs: