Які застереження щодо реалізації фундаментальних типів (наприклад, int) як класів?


27

При проектуванні і implenting об'єктно-орієнтована мова програмування, в якій - то момент один повинен зробити вибір про реалізацію основних типів (як int, float, doubleабо їх еквіваленти) , як класи або що - то інше. Зрозуміло, що мови в сім'ї С мають тенденцію не визначати їх як класи (Java має спеціальні примітивні типи, C # реалізує їх як незмінні структури тощо).

Я можу подумати про дуже важливу перевагу, коли основні типи реалізуються як класи (у типовій системі з єдиною ієрархією): ці типи можуть бути належними підтипами Лискова кореневого типу. Таким чином, ми уникаємо ускладнення мови бокс / розблокування (явне або неявне), типи обгортки, спеціальні правила дисперсії, особлива поведінка тощо.

Звичайно, я частково можу зрозуміти, чому мовні дизайнери вирішують, як вони роблять: екземпляри класів, як правило, мають певні просторові накладні витрати (оскільки екземпляри можуть містити vtable або інші метадані в їх макеті пам'яті), що примітивам / структурам не потрібно є (якщо мова не дозволяє успадкувати їх).

Чи просторова ефективність (і поліпшена просторова локальність, особливо у великих масивах) є єдиною причиною, чому фундаментальні типи часто не є класами?

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

Чи є вищезгадане неправильним, чи щось мені не вистачає?


Відповіді:


19

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

По-перше, це не просто "просторові накладні витрати". Перетворення примітивів у коробку / виділення купи також має витрати на продуктивність. Існує додатковий тиск на ГК для розподілу та збору цих об'єктів. Це подвоюється, якщо "примітивні об'єкти" незмінні, як це має бути. Тоді більше пропусків кешу (як через непрямість, так і тому, що менше даних вписується в задану кількість кешу). Плюс до того, що "завантажувати адресу об'єкта, а потім завантажувати фактичне значення з цієї адреси", потрібно більше інструкцій, ніж "завантажувати значення безпосередньо".

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

Зважаючи на те, що виклик будь-якого методу (включаючи getters) або передача об'єкта як аргумент будь-якому іншому методу може допомогти об'єкту вирватися, вам знадобиться міжпроцедурний аналіз у всіх, крім самих тривіальних випадках. Це набагато дорожче і складніше.

І тоді трапляються випадки, коли речі справді втечують і їх неможливо оптимізувати. Дуже багато з них, насправді, якщо врахувати, як часто програмісти C переживають проблеми, пов'язані з розподілом речей. Коли об'єкт, що містить int, втікає, аналіз втечі перестає застосовуватися і до int. Попрощайтеся з ефективними примітивними полями .

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

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


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

@TheodorosChatzigiannakis Я включаю зміну стратегії розподілу в аналізі втечі (тому що, чесно кажучи, це єдине, що вона коли-небудь використовується).

Повторюючи свій другий абзац: Об'єкти не завжди повинні бути виділені купуми або бути еталонними. Насправді, коли їх немає, це робить необхідні оптимізації порівняно просто. Для раннього прикладу див. Об’єкти, виділені стеком C ++, і систему власності Руста для способу передачі аналізу втечі безпосередньо на мову.
амон

@amon Я знаю, і, можливо, я мав би зробити це зрозумілішим, але, здається, ОП цікавить лише мови, схожі на Java та C #, де розподіл купи майже обов'язковий (і неявний) через референтну семантику та втрати без втрат між підтипами. Хороший момент про Rust, використовуючи те, що означає уникнути аналізу!

@delnan Це правда, я здебільшого цікавлюсь мовами, які абстрагують деталі зберігання, але, будь ласка, не соромтесь включати все, що вважаєте за потрібне, навіть якщо це не застосовується до цих мов.
Теодорос Чатзіґянакікіс

27

Чи просторова ефективність (і поліпшена просторова локальність, особливо у великих масивах) є єдиною причиною, чому фундаментальні типи часто не є класами?

Ні.

Інше питання полягає в тому, що фундаментальні типи, як правило, використовуються в фундаментальних операціях. Компілятор повинен знати, що int + intзбирається не до виклику функції, а до елементарної інструкції CPU (або еквівалентного байт-коду). У той момент, якщо у вас є intзвичайний об'єкт, вам доведеться все-таки ефективно розпакувати річ.

Такі операції також не дуже добре вкладаються в підтипи. Ви не можете відправити інструкцію процесора. Ви не можете відправити з інструкції процесора. Я маю на увазі, що вся суть підтипу полягає в тому, щоб ви могли використовувати a, Dде можете B. Інструкції до процесора не є поліморфними. Щоб зробити це примітивом, вам доведеться обробляти їхні операції з логікою відправки, яка коштує в кілька разів більше операцій як простого додавання (або будь-якого іншого). Користь від того, щоб intбути частиною ієрархії типу, стає невеликим спором, коли вона запечатана / остаточна. І це ігнорування всіх головних болів з логікою диспетчеризації бінарних операторів ...

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


4
Перевірте реалізацію будь-якої з динамічно набраних мов, які обробляють цілі числа та такі, як об’єкти. Кінцева примітивна інструкція CPU дуже добре може бути прихована в методі (перевантаження оператора) в єдино-дещо привілейованій реалізації класу в бібліотеці виконання. Деталі виглядатимуть по-різному зі системою статичного типу та компілятором, але це не є принциповою проблемою. У гіршому випадку це робить все ще повільніше.

3
int + intможе бути звичайним оператором на рівні мови, який викликає внутрішню інструкцію, яку гарантовано компілювати до (або поводити себе як) нативного додавання цілого процесора до цілої кількості оп. Користь від intуспадкування від objectне тільки можливості успадкування іншого типу від int, але й можливості intповодитися як objectбокс без. Розгляньте загальну інформацію про C #: ви можете мати коваріацію та противаріантність, але вони застосовні лише для типів класів - типи структури автоматично виключаються, оскільки вони можуть стати лише objectчерез (неявний, створений компілятором) бокс.
Теодорос Чатзіґянакікіс

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

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

1
@TheodorosChatzigiannakis Визначення меж бібліотеки, безумовно, можливо, хоча це ще один пункт у списку покупок "оптимізації високого класу, які я хотів би мати". Я відчуваю обов'язок зазначити, що, як відомо, хитро, щоб стати повністю правильним, не будучи таким консервативним, щоб бути марним.

4

Лише дуже мало випадків, коли для повноцінних об'єктів потрібні "фундаментальні типи" (тут об’єкт - це дані, які або містять вказівник на механізм відправки, або позначені типом, який може використовуватися диспетчерським механізмом):

  • Ви хочете, щоб визначені користувачем типи мали можливість успадковувати від основних типів. Зазвичай цього не потрібно, оскільки він вводить головні болі, пов'язані з працездатністю та безпекою. Це проблема продуктивності, тому що компіляція не може припустити, що у документа intбуде визначений фіксований розмір або що жодних методів не було відмінено, і це проблема безпеки, оскільки семантика ints може бути перервана (розглянемо ціле число, яке дорівнює будь-якому номеру, або що змінює своє значення, а не незмінне).

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

    Це можна вирішити, зробивши такі типи незаконними: позбудься від підтипів і вирішиш, що інтерфейси - це не типи, а обмеження типу. Очевидно, що це знижує виразність вашої системи типів, і така система типів уже не називатиметься об'єктно-орієнтованою. Дивіться Haskell про мову, яка використовує цю стратегію. C ++ знаходиться на півдорозі, оскільки примітивні типи не мають супертипів.

    Альтернатива - повний або частковий бокс основних типів. Тип боксу не повинен бути видимим для користувачів. По суті, ви визначаєте внутрішній тип коробки для кожного основного типу та неявні перетворення між коробкою та фундаментальним типом. Це може стати незручним, якщо типи коробки мають різну семантику. У Java є дві проблеми: типи в коробці мають поняття ідентичності, тоді як примітиви мають лише поняття еквівалентності значення, а типи в ящику є нульовими, тоді як примітиви завжди дійсні. Ці питання можна повністю уникнути, якщо не запропонувати поняття ідентичності для типів значень, запропонувавши оператору перевантаження і не зробивши всі об'єкти зведеними за замовчуванням.

  • Ви не маєте статичного набору тексту. Змінна може містити будь-яке значення, включаючи примітивні типи або об'єкти. Тому всі примітивні типи потрібно завжди містити в коробках, щоб гарантувати сильне друкування.

Мови, які мають статичний набір тексту, добре застосовують примітивні типи, де це можливо, і лише в крайньому випадку повертаються до типів, розміщених у коробці. Хоча багато програм не дуже чутливі до продуктивності, є випадки, коли розмір і склад примітивних типів надзвичайно актуальні: подумайте про масштабне скорочення чисельності, де потрібно помістити мільярди точок даних у пам'ять. Перехід від doubleдоfloatможе бути життєздатною стратегією оптимізації простору в C, але це не мало би ефекту, якщо всі числові типи завжди знаходяться в коробці (і, отже, витрачайте принаймні половину своєї пам'яті на покажчик механізму відправки). Коли примітивні типи в коробці використовуються локально, видалити бокс досить просто, використовуючи властивості компілятора, але було б недалекоглядним зробити ставку на загальну продуктивність вашої мови на "достатньо просунутий компілятор".


intНавряд чи незмінна на всіх мовах.
Скотт Вітлок

6
@ScottWhitlock Я розумію, чому ви можете так подумати, але загалом примітивні типи - це непорушні типи значень. Жодна розумна мова не дозволяє змінити значення числа сім. Однак багато мов дозволяють перепризначити змінну, яка містить значення примітивного типу на інше значення. У мовах, подібних С, змінна - це назване місце пам’яті та діє як вказівник. Змінна не є такою ж, як значення, на яке вона вказує. intЗначення є незмінним, але intзмінна не є.
амон

1
@amon: Немає здорової мови; просто Java: thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Мейсон Уілер

get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer але це звучить як програмування на основі прототипу, що, безумовно, є OOP.
Майкл

1
@ScottWhitlock питання в тому, якщо у вас тоді є int b = a, ви можете зробити щось з b, що змінить значення a. Існували деякі мовні реалізації, де це можливо, але це, як правило, вважається патологічним та небажаним, на відміну від того, як робити те ж саме для масиву.
Випадково832

2

Більшість реалізацій, які мені відомі, накладають три обмеження для таких класів, які дозволяють компілятору ефективно використовувати примітивні типи як основне представлення переважну більшість часу. Ці обмеження:

  • Незмінюваність
  • Кінцевість (не може бути отримана з)
  • Статичне введення тексту

Ситуації, коли компілятору потрібно встановити поле примітиву в об'єкт у нижньому представленні, є порівняно рідкісними, наприклад, коли Objectпосилання на нього вказує.

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


1

У Smalltalk всі вони (int, float тощо) є об'єктами першого класу. Тільки особливий випадок, SmallIntegers кодифіковані і трактуються по- різному віртуальної машини заради ефективності, і , отже , клас SmallInteger не допустять підкласи (який не є практичним обмеження.) Зверніть увагу , що це не потребує будь - яких особливої уваги з боку програміста, так як відмінність обмежується автоматичними процедурами, такими як генерація коду або збирання сміття.

І компілятор Smalltalk (вихідний код -> VM байт-коди), і VM nativizer (байт-коди -> машинний код) оптимізують створений код (JIT), щоб зменшити штраф за елементарні операції з цими основними об'єктами.


1

Я розробляв OO ланцюг та час виконання (це не вдалося із зовсім іншого набору причин).

Немає нічого поганого в тому, щоб робити такі речі, як справжні класи; насправді це полегшує проектування GC, оскільки зараз існує лише два види заголовків купи (клас та масив), а не 3 (клас, масив та примітивний) [той факт, що ми можемо об'єднати клас та масив після цього, не має значення ].

Справжній важливий випадок, що примітивні типи повинні мати переважно остаточні / запечатані методи (+ насправді має значення, ToString не так багато). Це дозволяє компілятору статично вирішувати майже всі виклики самих функцій та вбудовувати їх. У більшості випадків це не має значення як поведінка при копіюванні (я вирішив зробити вбудовування доступним на мовному рівні [так зробив .NET]), але в деяких випадках, якщо методи не запечатані, компілятор буде змушений генерувати виклик в функція, що використовується для реалізації int + int.

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