Пъзел за рубинените константи

Алгоритъмът на Ruby за намиране на дефиницията на константа е по-сложен, отколкото си мислите.

Онзи ден правех рефакторинг в Rails CMS, който обслужва thriveglobal.com. Позволяваме на нашите редактори да превключват няколко булеви атрибута на историите – да ги обозначават със звезда, да ги маркират и т.н. – и бихме изсушили контролерите за тези атрибути, като ги подкласираме към абстрактен контролер, StoryBooleansController:

class StoryBooleansController < BaseController
  def create
    update_story(story_boolean => true)
  end

  def destroy
    update_story(story_boolean => false)
  end

  private

  def update_story(attributes)
    story = Story.find(params[:story_id])
    story.update!(attributes)
  end
end

Дъщерните контролери внедриха метода #story_boolean за връщане на съответния атрибут, както следва:

class StarsController < StoryBooleansController
  private

  def story_boolean
    :starred
  end
end

Забелязвайки, че този метод винаги връща статична стойност, се чудех дали трябва да бъде константа вместо метод. Така че преработих така:

class StoryBooleansController < BaseController
  def create
    update_story(STORY_BOOLEAN => true)
  end

  def destroy
    update_story(STORY_BOOLEAN => false)
  end

  # [SNIP]
end
class StarsController < StoryBooleansController
  STORY_BOOLEAN = :starred
end

Проведох тестовете и за моя изненада открих грешка: NameError: uninitialized constant STORY_BOOLEAN. Кодът, който извикваше константата, не можа да я намери, въпреки че успя да намери метод в същия контекст. хаха! какво става тук

Пъзелът, дестилиран

Методът, който промених в константа, е дефиниран в един клас, но се извиква от неговия суперклас. Ето един пример, който дестилира структурата на кода, с който работех:

class MyClass
  def foo_via_method
    foo_method
  end

  def foo_via_constant
    FOO_CONSTANT
  end
end

class SubClass < MyClass
  FOO_CONSTANT = "foo"

  def foo_method
    FOO_CONSTANT
  end
end

Оригиналният код беше аналогичен на извикването на SubClass.new.foo_via_method. След рефакторинг новият код беше аналогичен на извикването на SubClass.new.foo_via_constant. Можем да пресъздадем моя неуспешен рефактор, като забележим, че версията на метода работи, но версията на константата се проваля:

sub_class_instance = SubClass.new
### THIS WORKS ###
sub_class_instance.foo_via_method
# => "foo"

### THIS DOESN'T ###
sub_class_instance.foo_via_constant
# NameError: uninitialized constant MyClass::FOO_CONSTANT

Версията, която препраща към метод в подкласа, връща желаната стойност, но версията, която препраща към константа в подкласа, извежда грешка. И така, пъзелът е следният: Защо версията, която използва метод, работи, но версията, която използва константата, се проваля?

Търсене на метод

Нека да разгледаме по-отблизо какво се случва, когато извикаме успешното #foo_via_method. Ето грубо описание на високо ниво на това как Ruby оценява този метод:

  1. Потърсете дефиниция на #foo_via_method в класа на получателя, SubClass. Не там!
  2. Потърсете дефиниция на #foo_via_method в суперкласа на SubClass, MyClass. Намерих го!
  3. Предайте на получателя съобщението foo_method.
  4. Потърсете стойността на FOO_CONSTANT: "foo". (Ще навляза в много, много повече подробности за това как работи това постоянно търсене в това, което следва.)

Стъпки (1) и (2) илюстрират алгоритъма за търсене на метод на Ruby. Ruby търси дефиницията на метода в класа на получателя и ако не може да я намери, Ruby повтаря веригата на суперкласовете (известна също като „веригата на предшественика“), докато намери клас, който имплементира метода. (Ако стигне чак до BasicObject и зачертае, той извиква #method_missing.)

Така че това е сделката с търсенето на метод. Всичко това е доста глупаво за тези с опит в работата с Ruby, или наистина всеки език, който поддържа наследяване на класове. Но защо константите не се държат по същия начин?

Постоянно търсене

Ако подозирате, че постоянното търсене работи по подобен начин, до голяма степен сте прави. Както при методите, Ruby също може да преглежда веригата на суперкласовете, за да намери константи. Ето една демонстрация:

class MyOtherClass
  NAME = "Michael"
end

class OtherSubClass < MyOtherClass
end

OtherSubClass::NAME
# => "Michael"

Ruby може да разреши OtherSubClass::NAME, въпреки че тази константа не е дефинирана в OtherSubClass, а по-скоро в неговия суперклас, MyOtherClass. Това е същото поведение при търсене, което доведе до успех на #foo_via_method.

Така че защо #foo_via_constant се проваля?

Отговорът е, че макар и двата алгоритъма за търсене да търсят дефиниции в предшествениците на текущия клас, те се различават по начина, по който определят кой клас се счита за „текущият клас“. За търсене на метод текущият клас – първият клас във веригата на предците, през който Ruby ще премине в търсене на дефиниция – е класът на получателя. Когато извикаме #foo_via_method на sub_class_instance, стойността на self в тялото на този метод е нашият приемник, sub_class_instance. Така че текущият клас за целите на търсенето на #foo_method е SubClass.

Постоянното търсене определя „текущия клас“ по различен начин. Вместо да разчита на получателя да определи текущия клас, константното търсене започва с класа, съдържащ метода. За да бъдем по-точни, постоянното търсене започва своето търсене по веригата на суперклас, използвайки класа, съдържащ текущия лексикален обхват. (Ако няма отворен клас в текущия обхват, Ruby започва с класа Object.)

Лексикалният обхват е контекстът, определен от това къде се намирате в кода. Това е, което позволява локалните променливи да бъдат дефинирани в блок, без да се засягат променливи извън блока, например. И така, докато търсенето на метод е относително към приемника, на който е извикан методът, постоянното търсене е относително само към мястото в кода, където Ruby среща константата.

(Полезни инструменти: Можете да инспектирате йерархията на лексикалния обхват, като препратите към Module.nesting във всеки контекст. Можете да инспектирате верига от суперкласове, като извикате #ancestors на клас или модул. За добро време опитайте да извикате .ancestors.count на клас на модел ActiveRecord в зрял Rails ап.)

Отиване до Източника

За да обоснова моите твърдения за това как Ruby намира постоянни дефиниции, нека се потопим в изходния код на Ruby, който всъщност прилага този алгоритъм. (Забележка: За тази дискусия ще се съсредоточа само върху YARV внедряването на Ruby, версия 2.4.1.)

Нека проучим какво прави Ruby, когато извикаме sub_class_instance.foo_via_constant и се натъкне на константата FOO_CONSTANT в тялото на този метод. Когато имаме достъп до константа, виртуалната машина на Ruby, YARV, извиква инструкцията getconstant, дефинирана тук. Нека да разгледаме коментара за тази функция:

Вземете идентификатор на постоянна променлива. Ако класът е Qnil, константите се търсят в текущия обхват. Ако klass е Qfalse, константите се търсят като константи от най-високо ниво. В противен случай вземете константа под клас клас или модул.

Qnil и Qfalse са начинът на YARV да се позовава на Ruby nil и false. Параметърът klass се отнася до изричния обхват, който прилагаме към константа, когато я извикваме. напр. ако извикаме SubClass::FOO_CONSTANT, klass ще бъде SubClass. В нашия пъзел имаме „голо“ извикване на FOO_CONSTANT, за което klass е Qnil. Така че това, което коментарът ни казва, е, че когато срещнем гола константа, тя ще бъде „търсена в текущия обхват“. Това звучи обещаващо! Нека пътуваме надолу по стека на повикванията, за да видим какво наистина означава това на практика.

getconstant извиква vm_get_ev_const, „приятелска 75-редова функция“, която всъщност прилага постоянно търсене. Тази функция получава четири параметъра: нишка; изричният контекст на класа, orig_class; идентификатор за константата; и параметър за кеширане, is_defined, със стойност 0. Интересуваме се от голо постоянно обаждане, така че orig_klass ще бъде Qnil. Поглеждайки в рамките на блока if (orig_klass = Qnil), първото нещо, което функцията прави, е да инициализира локална променлива, cref (съкратено от „кодова справка“). Тази променлива съдържа корена на веригата на лексикалния обхват, който представлява мястото в кода, където е била открита константата.

След това дългият блок while итерира веригата на лексикалния обхват, като проверява на всяка стъпка по пътя, за да види дали константата е дефинирана в този контекст. Линията cref = CREF_NEXT(cref) е мястото, където правим стъпка нагоре по веригата. Рутината продължава да се изкачва по веригата, докато намери обхват, в който константата е дефинирана, или докато накрая няма следващ cref, в който случай излизаме от блока while. Това е последното, което ще се появи в нашия пъзел, когато извикаме SubClass.new.foo_via_constant; Константата FOO_CONSTANT не е дефинирана в основния лексикален обхват, този в MyClass, и така търсенето в лексикалния обхват ще излезе празно.

Но YARV не спира търсенето си дотук. Това е следващата част от кода, която е най-важна за нашия пъзел:

/*********************************************
From vm_insnhelper.c in definition of `vm_get_ev_const`
*********************************************/
/* search self */
if (root_cref && !NIL_P(CREF_CLASS(root_cref))) {
  klass = vm_get_iclass(th->cfp, CREF_CLASS(root_cref));
}
else {
  klass = CLASS_OF(th->cfp->self);
}

if (is_defined) {
  return rb_const_defined(klass, id);
}
else {
  return rb_const_get(klass, id);
}

Сега YARV ще се опита да разреши константата, като прегледа йерархията на суперкласа. Първата работа на YARV е да идентифицира класа, който ще използва като корен на тази йерархия. Как се определя този клас е точно това, което се надявахме да научим.

Основният лексикален обхват е в контекста на клас (MyClass), така че условието

root_cref && !NIL_P(CREF_CLASS(root_cref))

е удовлетворено и klass се инициализира, както следва:

klass = vm_get_iclass(th->cfp, CREF_CLASS(root_cref));

Функцията vm_get_iclass просто връща класа, който е предал като втори аргумент (вижте дефиницията тук), така че на klass се присвоява MyClass. is_defined беше предадено в vm_get_ev_const като 0, което е невярно, така че върнатата стойност за постоянното търсене, което ни интересува, ще бъде rb_const_get(klass, id), където klass е MyClass.

Добре, почти сме у дома! rb_const_get извиква rb_const_get_0, което от своя страна се опитва да търси константата чрез повикване към rb_const_search. Тази функция за търсене преглежда йерархията на суперкласа, започвайки с класа, който е преминал - в нашия случай, MyClass. За да видите, че това е, което rb_const_search прави, имайте предвид, че точно преди блока за повторен опит, tmp е настроен на klass и ние се придвижваме нагоре в йерархията всеки път, когато ударим tmp = RCLASS_SUPER(tmp) тук. Уместно нареченият RCLASS_SUPER взема клас Ruby и връща неговия суперклас.

И така, ето го! Когато извикаме sub_class_instance.foo_via_constant, Ruby търси FOO_CONSTANT в MyClass и неговите суперкласове. Той никога не търси в SubClass и затова не може да намери дефиниция за константата.

Обобщаване: метод срещу постоянно търсене

В последния раздел установихме, че алгоритъмът за постоянно търсене на Ruby работи по следния начин:

  1. Проверете дали константата е дефинирана в текущия лексикален обхват.
  2. Ако не, преминете нагоре в йерархията на лексикалния обхват и се върнете към (1).
  3. Ако обхватите ви свършат и все още не сте разрешили константата, продължете напред.
  4. Проверете дали константата е дефинирана в класа, който е отворен в текущия лексикален обхват.
  5. Ако не, преместете йерархията на суперкласа и се върнете към (4).
  6. Ако пак зачертаете отново: Грешка!

Удебеленият текст в стъпка (4) илюстрира разликата между алгоритъма за търсене на верижни суперкласове на Ruby за константи и неговия подобен алгоритъм за методи. За методи търсенето започва с класа на получателя. За константи търсенето започва с класа, в който се намирате, на мястото на кода, където се извиква константата, известно още като лексикален обхват.

(Забележка: ние игнорираме допълнително усложнение на този алгоритъм, причинено от способността на Ruby да инициализира лениво или „автоматично зареждане“ константи. Това усложнение не е от значение за нашия пъзел.)

Защо Ruby работи по този начин?

Ruby е прост на външен вид, но е много сложен отвътре...

–Мац

Защо Ruby не използва същия алгоритъм за търсене на верига от суперкласове за константи, който използва за методи? И беше ли неразумно да очаквам, че може?

Когато извикваме Ruby метод, винаги има обект, на който го извикваме. Извикванията на метод са съобщения, които се предават на обект, „получателят“ на съобщението. Този обект може да бъде изрично споменат в кода (some_receiver.some_method) или може да бъде оставен имплицитно (some_method), в който случай приемникът по подразбиране използва текущата стойност на self. И в двата случая, където и да намери извикване на метод, Ruby има уникален обект, с който да работи. Всички Ruby обекти имат клас и Ruby може – и го прави – да търси дефиницията на метода в този клас и неговите предци.

Константите, от друга страна, не са съобщения, предавани на обекти. Когато имаме достъп до константа, това действие не е свързано с конкретен обект. Така че, освен ако не предоставим на Ruby изричен клас за разглеждане, като префиксираме нашата константа с SomeClass::, единственият наличен обхват за търсене е лексикалния обхват. Това е мястото, където Ruby търси.

Защо изобщо очаквах друго? В случая с моя пъзел просто така се случи, че постоянното ми извикване се случи в контекста на извикване на метод. Така че в моя пъзел имаше референтен обект, sub_class_instance, който на теория може да се използва за определяне на обхват на клас. Но това обикновено не е вярно за константни извиквания, тъй като константите могат да бъдат достъпни извън контекста на извиквания на метод. Независимо от това, очевидното съществуване на референтен обект ме изкуши да си помисля, че Ruby ще го използва за разрешаване на FOO_CONSTANT.

Матц, създателят на Ruby, „каза“, че „се опитва да направи Ruby естествен, а не прост“. Той е готов да направи внедряването на Ruby по-сложно, за да направи интерфейса му органичен и без триене. Потребителите на Ruby възприемат този принцип на проектиране и са започнали да очакват езикът им „просто да работи“. Това правех, когато рефакторирах моя контролер. Разбира се, би било по-сложно за Ruby да направи изключение за константи, срещани в контекста на извиквания на метод. Но ако беше така, щяхме да правим неща като sub_class_instance.foo_via_constant, които изглеждат естествени. Като се има предвид, че Руби прави всичко възможно да бъде естествена, не мисля, че очакването на Руби да се държи по този начин е неразумно.

И какво да кажем за този контролер?

Всичко започна, защото намерих за изненадващо, че код като следния не работи:

class StoryBooleansController < BaseController
  def create
    update_story(STORY_BOOLEAN => true)
  end

  def destroy
    update_story(STORY_BOOLEAN => false)
  end

  private

  def update_story(attributes)
    story = Story.find(params[:story_id])
    story.update!(attributes)
  end
end
class StarsController < StoryBooleansController
  STORY_BOOLEAN = :starred
end

Ако наистина исках да използвам константа тук и ако исках да продължа да поддържам много подкласове на StoryBooleansController, какво бих могъл да направя?

Едно решение би било да предоставим изричен обхват на класа, когато извикваме константата, така:

class StoryBooleansController < BaseController
  def create
    update_story(self.class::STORY_BOOLEAN => true)
  end

  def destroy
    update_story(self.class::STORY_BOOLEAN => false)
  end

  # [SNIP]
end

С тази промяна getconstant ще бъде извикано с klass, зададено на контролера на подкласа — StarsController в нашия пример — и веднага ще намери константата, дефинирана там. Дали този код е по-добър или не от това, с което започнах, оставям на преценката на читателя, но поне сега разбираме как и защо работи.

Благодаря!

Никога не бих си помислил да сляза в тази заешка дупка, ако не беше отличната книга на Pat Shaughnessy за вътрешността на Ruby, Ruby Under a Microscope. Книгата ми послужи като основен справочен текст за тази публикация. За удобство можете да прочетете главата за метода и постоянното търсене онлайн безплатно (PDF). След това отидете на „купете го“! Пат също щедро отговори на въпросите ми в Twitter и ми помогна да разбера къде да търся кодовата база на Ruby. Благодаря, Пат!

Благодаря и на приятели и колеги, които обсъдиха или прочетоха чернови на тази публикация, включително: Becca Liss, Bryan Mytko, Greg Bednarek, Karl Rosaen ​​и Mae Capozzi.