Чи повинен "Set" мати метод Get?


22

Давайте цей клас C # (це було б майже те саме в Java)

public class MyClass {
   public string A {get; set;}
   public string B {get; set;}

   public override bool Equals(object obj) {
        var item = obj as MyClass;

        if (item == null || this.A == null || item.A == null)
        {
            return false;
        }
        return this.A.equals(item.A);
   }

   public override int GetHashCode() {
        return A != null ? A.GetHashCode() : 0;
   }
}

Як бачимо, рівність двох примірників MyClassзалежить Aтільки від. Таким чином, можуть бути два екземпляри, які є рівними, але містять різні відомості у своїй Bвласності.

У стандартній бібліотеці колекцій багатьох мов (включаючи C # і Java, звичайно) є Set( HashSetу C #) - це колекція, яка може вмістити щонайменше один елемент з кожного набору рівних екземплярів.

Можна додавати предмети, видаляти предмети та перевіряти, чи містить набір предмет. Але чому неможливо отримати певний предмет із набору?

HashSet<MyClass> mset = new HashSet<MyClass>();
mset.Add(new MyClass {A = "Hello", B = "Bye"});

//I can do this
if (mset.Contains(new MyClass {A = "Hello", B = "See you"})) {
    //something
}

//But I cannot do this, because Get does not exist!!!
MyClass item = mset.Get(new MyClass {A = "Hello", B = "See you"});
Console.WriteLine(item.B); //should print Bye

Єдиний спосіб отримати мій предмет - це повторити повну колекцію та перевірити всі предмети на рівність. Однак на це потрібен O(n)час замість O(1)!

Я досі не знайшов жодної мови, яка підтримує, набір із набору. Усі "загальні" мови, які я знаю (Java, C #, Python, Scala, Haskell ...) здаються створеними однаково: ви можете додавати елементи, але ви не можете їх отримати. Чи є якась вагома причина, чому всі ці мови не підтримують щось таке легке і очевидно корисне? Вони не можуть бути помилковими, правда? Чи є мови, які це підтримують? Можливо, вилучення певного предмета з набору невірно, але чому?


Є декілька пов’язаних питань щодо ТА:

/programming/7283338/getting-an-element-from-a-set

/programming/7760364/how-to-retrieve-actual-item-from-hashsett


12
C ++ std::setпідтримує пошук об’єктів, тому не всі "загальні" мови є такими, як ви описані.
Поновіть Моніку

17
Якщо ви стверджуєте (і код), що "рівність двох екземплярів MyClass залежить лише від A", тоді інший екземпляр, який має те саме значення A і ефективно B, є "саме цей екземпляр", оскільки ви самі визначили, що вони рівні і відмінності в B не мають значення; контейнеру "дозволено" повертати інший екземпляр, оскільки він рівний.
Петріс

7
Справжня історія: у Java багато Set<E>реалізацій знаходяться лише Map<E,Boolean>зсередини.
corsiKa

10
Звертаючись до людини A : "Привіт, чи можете ви приїхати, будь ласка, сюди".
Бред Томас

7
Це порушує рефлексивність ( a == bзавжди вірно) у випадку this.A == null. if (item == null || this.A == null || item.A == null)Тест «перестаралися» і перевіряє багато, можливо , для того , щоб штучно створити «високої якості» коду. Я бачу таке "перевірка" і весь час надмірно коректуюсь у Code Review.
usr

Відповіді:


66

Проблема тут не в тому, що HashSetне вистачає Getметоду, це те, що ваш код не має сенсу з точки зору HashSetтипу.

Цей Getметод ефективно: "Прийміть мені це значення, будь ласка", на що фольклор .NET Framework відповідав би, "так? Ви вже маєте це значення <confused face />".

Якщо ви хочете зберігати предмети, а потім отримувати їх, грунтуючись на іншому трохи іншому значенні, тоді використовуйте так, Dictionary<String, MyClass>як ви можете:

var mset = new Dictionary<String, MyClass>();
mset.Add("Hello", new MyClass {A = "Hello", B = "Bye"});

var item = mset["Hello"];
Console.WriteLine(item.B); // will print Bye

Інформація про рівність витікає з інкапсульованого класу. Якби я хотів змінити набір властивостей, що беруть участь у роботі Equals, мені доведеться змінити код за межами MyClass...

Ну так, але це тому, що MyClassпрацює амок з принципом найменшого здивування (POLA). Враховуючи цю функціональність рівності, цілком розумно вважати, що дійсний наступний код:

HashSet<MyClass> mset = new HashSet<MyClass>();
mset.Add(new MyClass {A = "Hello", B = "Bye"});

if (mset.Contains(new MyClass {A = "Hello", B = "See you"})) 
{
    // this code is unreachable.
}

Щоб цього не допустити, MyClassнеобхідно чітко зафіксувати свою дивну форму рівності. Зробивши це, це вже не є інкапсульованим і змінюється, як працює ця рівність, порушує принцип відкритого / закритого типу. Ерго, це не повинно змінюватися, тому Dictionary<String, MyClass>є хорошим рішенням для цієї дивної вимоги.


2
@vojta, У такому випадку використовуйте, Dictionary<MyClass, MyClass>оскільки воно отримає значення на основі ключа, який використовується MyClass.Equals.
Девід Арно

8
Я б використав Dictionary<MyClass, MyClass>поставлений з відповідним IEqualityComparer<MyClass>, і витягніть відношення еквівалентності з того, MyClassчому він MyClassповинен знати про це відношення над його примірниками?
Калет

16
@vojta та коментар там: " Мех. Переосмислення реалізації рівних, щоб нерівні об'єкти були" рівними ", тут проблема. Просити метод, який говорить" дістань мені ідентичний об'єкт цьому об'єкту ", а потім очікуємо, що неідентичний об’єкт буде повернутий здається божевільним і легко викликати проблеми з технічним обслуговуванням ". Це часто проблеми з ТА: серйозно недосконалі відповіді отримують люди, які не роздумували над бажанням швидкого виправлення свого зламаного коду ...
Девід Арно

6
@DavidArno: вид неминучий, хоча ми наполегливо використовуємо мови, які розрізняють рівність та ідентичність ;-) Якщо ви хочете канонізувати об'єкти, які є рівними, але не тотожними, то вам потрібен метод, який говорить не "дістань мені ідентичне" заперечують проти цього об’єкта ", але" знайдіть мені канонічний об'єкт, рівний цьому об'єкту ". Кожен, хто думає, що HashSet.Get цими мовами обов'язково означатиме "дістань мені ідентичний об'єкт", вже сильно помиляється.
Стів Джессоп

4
У цій відповіді є багато тверджень, таких як ...reasonable to assume.... Все це може бути правдою в 99% випадків, але все-таки можливість отримати предмет з набору може стати в нагоді. Код реального світу не завжди може дотримуватися принципів POLA тощо. Наприклад, якщо ви присвоюєте рядки без регістру, ви можете отримати "головний" елемент. Dictionary<string, string>це обхідне рішення, але це коштує перф.
usr

24

У вас вже є елемент, який є "в" наборі - ви передали його як ключ.

"Але це не той випадок, який я назвав" Додати "- Так, але ви спеціально заявили, що вони рівні.

А Setтакож є особливим випадком Map| Dictionary, з пустотою як типом значення (ну і непотрібні методи не визначені, але це не має значення).

Структура даних, яку ви шукаєте, це те, Dictionary<X, MyClass>де Xякимось чином виводить As з MyClasses.

Тип словника C # є гарним у цьому плані, оскільки дозволяє поставити IEqualityComparer для ключів.

Для наведеного прикладу я маю наступне:

public class MyClass {
   public string A {get; set;}
   public string B {get; set;}
}

public class MyClassEquivalentAs : IEqualityComparer<MyClass>{
   public override bool Equals(MyClass left, MyClass right) {
        if (Object.ReferenceEquals(left, null) && Object.ReferenceEquals(right, null))
        {
            return true;
        }
        else if (Object.ReferenceEquals(left, null) || Object.ReferenceEquals(right, null))
        {
            return false;
        }
        return left.A == right.A;
   }

   public override int GetHashCode(MyClass obj) {
        return obj?.A != null ? obj.A.GetHashCode() : 0;
   }
}

Використовується таким чином:

var mset = new Dictionary<MyClass, MyClass>(new MyClassEquivalentAs());
var bye = new MyClass {A = "Hello", B = "Bye"};
var seeyou = new MyClass {A = "Hello", B = "See you"};
mset.Add(bye);

if (mset.Contains(seeyou)) {
    //something
}

MyClass item = mset[seeyou];
Console.WriteLine(item.B); // prints Bye

Існує ряд ситуацій, коли може бути вигідним код, у якого є об'єкт, що відповідає ключу, замінити його посиланням на об'єкт, що використовується як ключ. Наприклад, якщо відомо, що багато рядків збігаються з рядком у хешованій колекції, заміна посилань на всі ці рядки з посиланнями на той, що знаходиться в колекції, може стати виграшним показником.
supercat

@supercat сьогодні, що досягається за допомогою a Dictionary<String, String>.
MikeFHay

@MikeFHay: Так, але, здається, мало неелегантним є необхідність зберігати кожну посилання на рядок двічі.
supercat

2
@supercat Якщо ви маєте на увазі ідентичний рядок, це просто рядкове інтернування. Використовуйте вбудовані речі. Якщо ви маєте на увазі якесь "канонічне" представлення (таке, якого неможливо досягти за допомогою простих методів зміни випадків тощо), то це здається, що вам в основному потрібен індекс (у сенсі, що БД використовують цей термін). Я не бачу проблеми зі зберіганням кожної "неканонічної форми" як ключа, який відображає канонічну форму. (Я думаю, що це однаково добре застосовується, якщо "канонічна" форма не є рядком.) Якщо ви не про це говорите, то ви повністю втратили мене.
jpmc26

1
На замовлення Comparerі Dictionary<MyClass, MyClass>є прагматичним рішенням. У Java те саме можна досягти за допомогою TreeSetабо TreeMapплюс користувачеві Comparator.
Маркус Кулл

19

Ваша проблема полягає в тому, що у вас є дві суперечливі концепції рівності:

  • фактична рівність, коли всі поля рівні
  • встановити рівність членства, де дорівнює лише А

Якщо ви використовуєте фактичне співвідношення рівності у вашому наборі, проблема з тим, щоб отримати певний елемент із набору, не виникає - щоб перевірити, чи є об’єкт у наборі, у вас вже є цей об’єкт. Тому ніколи не потрібно витягувати певний екземпляр із набору, припускаючи, що ви використовуєте правильне відношення рівності.

Ми також можемо стверджувати, що набір - це абстрактний тип даних, який визначається суто S contains xабо x is-element-of Sвідношенням ("характеристична функція"). Якщо ви хочете інших операцій, ви насправді не шукаєте набір.

Що трапляється досить часто - але те, що не є набором, - це те, що ми групуємо всі об'єкти в окремі класи еквівалентності . Об'єкти в кожному такому класі або підмножині є лише рівнозначними, не рівними. Ми можемо представляти кожен клас еквівалентності через будь-який член цього підмножини, і тоді стає бажаним отримати той елемент, що представляє. Це було б відображенням від класу еквівалентності до репрезентативного елемента.

У C # словник може використовувати явне відношення рівності, я думаю. В іншому випадку таке відношення можна реалізувати, написавши швидкий клас обгортки. Псевдокод:

// The type you actually want to store
class MyClass { ... }

// A equivalence class of MyClass objects,
// with regards to a particular equivalence relation.
// This relation is implemented in EquivalenceClass.Equals()
class EquivalenceClass {
  public MyClass instance { get; }
  public override bool Equals(object o) { ... } // compare instance.A
  public override int GetHashCode() { ... } // hash instance.A
  public static EquivalenceClass of(MyClass o) { return new EquivalenceClass { instance = o }; }
}

// The set-like object mapping equivalence classes
// to a particular representing element.
class EquivalenceHashSet {
  private Dictionary<EquivalenceClass, MyClass> dict = ...;
  public void Add(MyClass o) { dict.Add(EquivalenceClass.of(o), o)}
  public bool Contains(MyClass o) { return dict.Contains(EquivalenceClass.of(o)); }
  public MyClass Get(MyClass o) { return dict.Get(EquivalenceClass.of(o)); }
}

"отримати певний екземпляр з набору" Я думаю, що це передасть те, що ви маєте на увазі більш прямо, якщо ви змінили "екземпляр" на "член". Просто незначна пропозиція. =) +1
jpmc26

7

Але чому неможливо отримати певний предмет із набору?

Тому що це не те, для чого набори.

Дозвольте перефразувати приклад.

"У мене є HashSet, в якому я хочу зберігати об'єкти MyClass, і я можу отримати їх за допомогою властивості A, яка дорівнює властивості об'єкта A".

Якщо замінити "HashSet" на "Колекція", "об'єкти" на "Значення" та "Властивість А" на "Ключ", речення стає таким:

"У мене є колекція, в яку я хочу зберігати значення MyClass, і я хочу мати змогу отримати їх за допомогою ключа, що дорівнює ключу об'єкта".

Описується Словник. Справжнє запитання, яке мені задають: "Чому я не можу розглянути HashSet як словник?"

Відповідь полягає в тому, що вони не використовуються для одного і того ж. Причиною використання набору є гарантування унікальності його індивідуального вмісту, інакше ви можете просто скористатись списком або масивом. Поведінка, що описується у запитанні, є для чого Словник. Всі мовні дизайнери не збивали з глузду. Вони не забезпечують метод отримання, тому що якщо у вас є об'єкт і він є в наборі, вони є рівнозначними, а значить, ви б "отримували" еквівалентний об'єкт. Стверджуючи, що HashSet повинен бути реалізований таким чином, що ви можете "отримати" нееквівалентні об'єкти, які ви визначили рівними, це нестартовий механізм, коли мови надають інші структури даних, які дозволяють вам це робити.

Примітка до ООП та коментарі / відповіді щодо рівності. Добре, щоб ключ відображення був властивістю / членом збереженого значення у словнику. Наприклад: мати ключовий ключ як ключ, а також властивість, яка використовується для методу рівних, є цілком розумним. Нерозумно мати різні значення для решти властивостей. Я вважаю, що якщо я рухаюся в цьому напрямку, мені, мабуть, потрібно переглянути свою структуру класу.


6

Як тільки ви перейдете дорівнює, вам краще замінити хеш-код. Як тільки ви це зробите, ваш "екземпляр" більше ніколи не повинен змінювати внутрішній стан.

Якщо ви не переосмислюєте рівності, а для визначення рівності використовується ідентифікація об’єкта VM хеш-коду. Якщо ви помістите цей об'єкт у набір, ви зможете його знову знайти.

Зміна значення об'єкта, яке використовується для визначення рівності, призведе до непростежуваності цього об'єкта в структурах на основі хешу.

Тож сеттер на А небезпечний.

Тепер у вас немає Б, який не бере участі в рівності. Проблема тут семантично не технічно. Тому що технічно змінюється В є нейтральним до факту рівності. Семантично B має бути чимось на зразок прапора "версії".

Справа в тому:

Якщо у вас є два об'єкти, які дорівнюють A, але не B, у вас є припущення, що один з цих об'єктів новіший, ніж інший. Якщо у B немає інформації про версію, це припущення приховано у вашому алгоритмі, коли ви вирішите "перезаписати / оновити" цей об'єкт у наборі. Це місце розташування вихідного коду, де це відбувається, може бути не очевидним, тому розробнику буде важко визначити співвідношення між об'єктом X та об'єктом Y, яке відрізняється від X у B.

Якщо у B є інформація про версію, ви розкриваєте припущення, яке раніше було неявно виведене з коду. Тепер ви можете бачити, що об'єкт Y - це новіша версія X.

Подумайте про себе: ваша особистість залишається на все життя, можливо, деякі властивості змінюються (наприклад, колір вашого волосся ;-)). Впевнені, ви можете припустити, що якщо у вас є дві фотографії, одна з каштановим волоссям і одна з сивим волоссям, ви можете бути молодшими на фотографії з каштановим волоссям. Але, можливо, ви пофарбували волосся? Проблема полягає в тому, що ВАС може знати, що ви фарбували волосся. Можуть інші? Щоб поставити це в допустимий контекст, вам потрібно ввести вік властивості (версію). Тоді ви є семантично явними та однозначними.

Щоб уникнути прихованої операції "заміни старого на новий об'єкт", набір не повинен мати метод get-Method. Якщо ви хочете подібна поведінка, ви повинні зробити це явним, видаливши старий об'єкт і додавши новий.

BTW: Що це повинно означати, якщо ви переходите в об'єкт, який дорівнює об'єкту, який ви хочете отримати? Це не має сенсу. Зберігайте свою семантику в чистоті і не робіть цього, хоча технічно ніхто вам не завадить.


7
"Як тільки ви перейдете дорівнює, вам краще переотримати хеш-код. Як тільки ви це зробите, ваш" екземпляр "більше ніколи не повинен змінювати внутрішній стан." Це твердження варто +100, саме там.
Девід Арно

+1 за вказівку на небезпеку рівності та хеш-коду залежно від стану, що змінюється
Халк

3

Зокрема, у Java, HashSetспочатку реалізовано за допомогою HashMapбудь-якого, і просто ігноруючи значення. Таким чином, початковий дизайн не передбачав жодної переваги в наданні методу get HashSet. Якщо ви хочете зберігати і витягувати канонічне значення серед різних рівних об'єктів, тоді ви просто використовуєте HashMapсебе.

Я не тримав в курсі таких деталей реалізації, тому я не можу сказати, до сих пір застосовує ці міркування в повній мірі в Java, НЕ кажучи вже в C # і т.д. Але навіть якщо HashSetбули переписана , щоб використовувати менше пам'яті , ніж HashMap, у всякому разі, було б безперебійною зміною додати новий метод до Setінтерфейсу. Тож це досить багато болю за виграш, який не всі бачать як варто мати.


Що ж, у Java можна було б забезпечити default-виконання, щоб зробити це нерозривним способом. Це просто не здається дуже корисною зміною.
Халк

@Hulk: Можливо, я помиляюся, але я думаю, що будь-яка реалізація за замовчуванням була б неефективною, оскільки, як питає запитуючий, "Єдиний спосіб отримати мій товар - це повторити всю колекцію та перевірити всі предмети на рівність". Тож хороший момент, ви можете це зробити зворотним сумісним способом, але додавши gotcha, що отримана функція get гарантує лише O(n)порівняння, навіть якщо хеш-функція дає хороший розподіл. Тоді реалізація, Setщо переосмислює реалізацію за замовчуванням в інтерфейсі, включаючи HashSet, може дати кращу гарантію.
Стів Джессоп

Домовились - я не думаю, що це було б гарною ідеєю. Незважаючи на те, що для такої поведінки є переваги - List.get (int index) або - для вибору програми за замовчуванням, доданої нещодавно List.sort . Інтерфейс надає гарантії максимальної складності, але деякі реалізації можуть зробити набагато краще, ніж інші.
Халк

2

Існує основна мова, набір якої має властивість, яку ви хочете.

В C ++ std::set- це упорядкований набір. Він має .findметод, який шукає елемент на основі оператора замовлення <або бінарної bool(T,T)функції, яку ви надаєте. Ви можете використовувати find для реалізації потрібної операції get.

Насправді, якщо bool(T,T) функція, яку ви надаєте, має на ній певний прапор ( is_transparent), ви можете передати об'єкти іншого типу, для яких функція має перевантаження. Це означає, що вам не доведеться дотримуватися "фіктивних" даних у другому полі, лише переконайтеся, що операція замовлення, яку ви використовуєте, може замовляти між типом пошуку та вмістом, що містять набір.

Це дозволяє ефективно:

std::set< std::string, my_string_compare > strings;
strings.find( 7 );

де my_string_compareрозуміє, як замовити цілі числа та рядки без попереднього перетворення цілого числа на рядок (з потенційною вартістю).

Для unordered_set(хеш-набору C ++), немає еквівалентного прозорого прапора (поки). Ви повинні перейти Tдо аunordered_set<T>.find методу. Його можна додати, але хеші вимагають ==і хешерів, на відміну від упорядкованих наборів, які просто вимагають замовлення.

Загальна схема полягає в тому, що контейнер зробить пошук, а потім дасть вам "ітератор" для цього елемента в контейнері. У цей момент ви можете отримати елемент у межах набору, або видалити його тощо.

Коротше кажучи, не всі стандартні контейнери для мов мають ваші описи. Контейнери на основі ітератора на основі стандартної бібліотеки C ++ не мають, і принаймні деякі контейнери існували раніше, ніж будь-який з інших мов, які ви описали, і можливість зробити дію навіть ефективніше, ніж те, як ви описуєте, навіть була додана. У вашому дизайні чи бажанні цієї операції немає нічого поганого; дизайнери наборів, якими ви користуєтесь, просто не надали цей інтерфейс.

Стандартні контейнери C ++, які призначені для чіткого завершення операцій низького рівня еквівалентного ручного прокату C-коду, який був розроблений таким чином, щоб відповідати тому, як ви можете його ефективно записувати при складанні. Її ітератори - це абстракція покажчиків у стилі С. Усі мови, які ви згадуєте, віддалилися від покажчиків як поняття, тому вони не використовували абстракцію ітератора.

Можливо, те, що C ++ не має цієї вади, є випадковістю дизайну. Шлях, орієнтований на ітератор, означає, що для взаємодії з елементом в асоціативному контейнері спочатку ви отримуєте ітератор до елемента, потім використовуєте цей ітератор для розмови про запис у контейнері.

Вартість полягає в тому, що існують правила ітерації недійсності, які потрібно відстежувати, а для деяких операцій потрібно 2 кроки замість одного (що робить код клієнта шумнішим). Перевага полягає в тому, що надійна абстракція дозволяє більш досконало використовувати, ніж ті, які розробники API мали на увазі спочатку.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.