Stork, Bölüm 3: İfadeleri ve Değişkenleri Uygulamak
Yayınlanan: 2022-03-11Serimizin 3. Kısmında, hafif programlama dilimiz nihayet çalışacaktır. Turing-complete olmayacak, güçlü olmayacak, ancak ifadeleri değerlendirebilecek ve hatta C++ ile yazılmış harici fonksiyonları çağırabilecek.
Süreci mümkün olduğunca ayrıntılı bir şekilde açıklamaya çalışacağım, çünkü bu blog serisinin amacı bu, aynı zamanda kendi belgelerim için de çünkü bu bölümde işler biraz karmaşıklaştı.
İkinci makalenin yayınlanmasından önce bu kısım için kodlamaya başladım, ancak daha sonra ifade ayrıştırıcısının kendi blog gönderisini hak eden bağımsız bir bileşen olması gerektiği ortaya çıktı.
Bu, bazı kötü şöhretli programlama teknikleriyle birlikte, bu bölümün canavarca büyük olmamasını mümkün kıldı ve yine de, bazı okuyucular büyük olasılıkla söz konusu programlama tekniklerine işaret edecek ve neden bunları kullanmak zorunda kaldığımı merak edecek.
Neden Makro Kullanıyoruz?
Farklı projelerde ve farklı insanlarla çalışarak programlama deneyimi kazandıkça, geliştiricilerin oldukça dogmatik olma eğiliminde olduğunu öğrendim - muhtemelen böylesi daha kolay olduğu için.
Programlamanın ilk dogması, goto
ifadesinin kötü, kötü ve korkunç olmasıdır. Bu duygunun nereden geldiğini anlayabiliyorum ve birinin goto
ifadesini kullandığı vakaların büyük çoğunluğunda bu fikre katılıyorum. Genellikle önlenebilir ve bunun yerine daha okunabilir kod yazılabilir.
Bununla birlikte, C++'daki iç döngüden goto
deyimiyle kolayca gerçekleştirilebileceğini inkar edemezsiniz. Bir bool
değişkeni veya özel bir işlev gerektiren alternatif, dogmatik olarak yasaklanmış programlama teknikleri kovasına giren koddan daha az okunabilir olabilir.
Yalnızca C ve C++ geliştiricileri için geçerli olan ikinci dogma, makroların kötü, kötü, korkunç ve temelde gerçekleşmeyi bekleyen bir felaket olduğudur. Buna hemen hemen her zaman şu örnek eşlik eder:
#define max(a, b) ((a) > (b) ? (a) : (b)) ... int x = 3; int z = 2; int y = max(x++, z);
Ve sonra bir soru var: Bu kod parçasından sonra x
değeri nedir ve x
her iki tarafında birer tane olmak üzere iki kez artırıldığı için cevap 5
?
-Şebeke.
Tek sorun, bu senaryoda kimsenin makro kullanmamasıdır. Makrolar, sıradan işlevlerin iyi çalıştığı bir senaryoda kullanılırlarsa, özellikle de işlevlermiş gibi davranırlarsa kötüdür, bu nedenle kullanıcı yan etkilerinden habersizdir. Ancak, bunları işlev olarak kullanmayacağız ve işlev olmadıklarını açıkça göstermek için adları için blok harfler kullanacağız. Bunları düzgün bir şekilde ayıklayamayacağız ve bu kötü, ancak bununla yaşayacağız, çünkü alternatif aynı kodu düzinelerce kez kopyalayıp yapıştırmaktır, ki bu makrolardan çok daha fazla hataya açıktır. Bu soruna bir çözüm, kod oluşturucuyu yazmaktır, ancak zaten C++'a gömülü bir tane varken neden yazalım?
Programlamadaki dogmalar neredeyse her zaman kötüdür. Az önce kurduğum dogma tuzağına tekrar tekrar düşmemek için burada "neredeyse" ifadesini dikkatli bir şekilde kullanıyorum.
Bu bölüme ait kodu ve tüm makroları burada bulabilirsiniz.
Değişkenler
Bir önceki bölümde Stork'un binary ya da Assembly diline benzer bir şekilde derlenmeyeceğinden bahsetmiştim ancak bunun statik olarak yazılan bir dil olacağını da söylemiştim. Bu nedenle, derlenecek, ancak yürütülebilecek bir C++ nesnesinde. Daha sonra netlik kazanacak ama şimdilik tüm değişkenlerin kendi başlarına birer nesne olacağını belirtelim.
Bunları global değişken kapsayıcısında veya yığında tutmak istediğimizden, uygun bir yaklaşım temel sınıfı tanımlamak ve ondan miras almaktır.
class variable; using variable_ptr = std::shared_ptr<variable>; class variable: public std::enable_shared_from_this<variable> { private: variable(const variable&) = delete; void operator=(const variable&) = delete; protected: variable() = default; public: virtual ~variable() = default; virtual variable_ptr clone() const = 0; template <typename T> T static_pointer_downcast() { return std::static_pointer_cast< variable_impl<typename T::element_type::value_type> >(shared_from_this()); } };
Gördüğünüz gibi, oldukça basittir ve derin kopyayı yapan clone
işlevi, yıkıcı dışındaki tek sanal üye işlevidir.
Bu sınıfın nesnelerini her zaman shared_ptr
aracılığıyla kullanacağımız için, paylaşılan işaretçiyi kolayca alabilmemiz için onu std::enable_shared_from_this
devralmak mantıklıdır. static_pointer_downcast
işlevi kolaylık sağlamak için burada çünkü sık sık bu sınıftan uygulamasına geçiş yapmamız gerekecek.
Bu sınıfın gerçek uygulaması, sahip olduğu türle parametrelenmiş olan variable_impl
. Kullanacağımız dört tür için somutlaştırılacaktır:
using number = double; using string = std::shared_ptr<std::string>; using array = std::deque<variable_ptr>; using function = std::function<void(runtime_context&)>;
Sayı tipimiz olarak double
kullanacağız. Dizeler, değere göre aktarılırken belirli optimizasyonları etkinleştirmek için değişmez olacakları için referans sayılır. Dizi kararlı olduğu için std::deque
olacaktır ve runtime_context
çalışma zamanı sırasında program belleğiyle ilgili tüm bilgileri tutan sınıf olduğunu belirtelim. Buna daha sonra geleceğiz.
Aşağıdaki tanımlar da sıklıkla kullanılmaktadır:
using lvalue = variable_ptr; using lnumber = std::shared_ptr<variable_impl<number>>; using lstring = std::shared_ptr<variable_impl<string>>; using larray = std::shared_ptr<variable_impl<array>>; using lfunction = std::shared_ptr<variable_impl<function>>;
Burada kullanılan "l", "değer" için kısaltılmıştır. Ne zaman bir tür için bir değerimiz olsa, variable_impl
için paylaşılan işaretçiyi kullanacağız.
Çalışma Zamanı Bağlamı
Çalışma zamanı sırasında, bellek durumu runtime_context
sınıfında tutulur.
class runtime_context{ private: std::vector<variable_ptr> _globals; std::deque<variable_ptr> _stack; std::stack<size_t> _retval_idx; public: runtime_context(size_t globals); variable_ptr& global(int idx); variable_ptr& retval(); variable_ptr& local(int idx); void push(variable_ptr v); void end_scope(size_t scope_vars); void call(); variable_ptr end_function(size_t params); };
Global değişkenlerin sayısı ile başlatılır.
-
_globals
tüm global değişkenleri tutar. Mutlak indeks ileglobal
üye fonksiyonu ile erişilirler. -
_stack
, yerel değişkenleri ve işlev bağımsız değişkenlerini tutar ve_stack
üstündeki tamsayı, mutlak dizini geçerli dönüş değerinin_retval_idx
tutar. - Dönüş değerine
retval
işleviyle erişilirken, yerel değişkenlere ve işlev argümanlarına, geçerli dönüş değerine göre indeks geçirilereklocal
işlevle erişilir. Bu durumda fonksiyon argümanlarının negatif indeksleri vardır. -
push
işlevi, değişkeni yığına eklerkenend_scope
, iletilen değişken sayısını yığından kaldırır. -
call
işlevi yığını birer birer yeniden boyutlandıracak ve_stack
_retval_idx
. -
end_function
, yığından dönüş değerini ve iletilen argüman sayısını kaldırır ve ayrıca kaldırılan dönüş değerini döndürür.
Gördüğünüz gibi, herhangi bir düşük seviyeli bellek yönetimi uygulamayacağız ve doğal olarak kabul edebileceğimiz yerel (C++) bellek yönetiminden yararlanacağız. En azından şimdilik herhangi bir yığın tahsisi de uygulamayacağız.
runtime_context
ile nihayet bu bölümün merkezi ve en zor bileşeni için gereken tüm yapı taşlarına sahibiz.
İfade
Burada sunacağım karmaşık çözümü tam olarak açıklamak için, bu yaklaşıma karar vermeden önce yaptığım birkaç başarısız girişimi size kısaca tanıtacağım.
En kolay yaklaşım, her ifadeyi variable_ptr
olarak değerlendirmek ve bu sanal temel sınıfa sahip olmaktır:
class expression { ... public: variable_ptr evaluate(runtime_context& context) const = 0; lnumber evaluate_lnumber(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lnumber>(); } lstring evaluate_lstring(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lstring>(); } number evaluate_number(runtime_context& context) const { return evaluate_lnumber(context)->value; } string evaluate_string(runtime_context& context) const { return evaluate_lstring(context)->value; } ... }; using expression_ptr = std::unique_ptr<expression>;
Daha sonra toplama, birleştirme, işlev çağrısı vb. gibi her işlem için bu sınıftan miras alırız. Örneğin, bu toplama ifadesi uygulaması olacaktır:
class add_expression: public expression { private: expression_ptr _expr1; expression_ptr _expr2; public: ... variable_ptr evaluate(runtime_context& context) const override{ return std::make_shared<variable_impl<number> >( _expr1->evaluate_number(context) + _expr2->evaluate_number(context) ); } ... };
Bu yüzden her iki tarafı da ( _expr1
ve _expr2
) değerlendirmemiz, bunları eklememiz ve ardından variable_impl<number>
oluşturmamız gerekiyor.
Derleme sırasında türlerini kontrol ettiğimiz için değişkenleri güvenle indirebiliriz, yani buradaki sorun bu değil. Bununla birlikte, büyük sorun, teorik olarak gerekli olmayan, geri dönen nesnenin yığın tahsisi için ödediğimiz performans cezasıdır. Bunu sanal işlev bildirimini karşılamak için yapıyoruz. Stork'un ilk versiyonunda fonksiyonlardan sayı döndürdüğümüzde o cezayı alacağız. Bununla yaşayabilirim, ancak yığın ayırma yapan basit artış öncesi ifadeyle yaşayamam.
Ardından, ortak tabandan miras alınan türe özgü ifadeleri denedim:
class expression { ... public: virtual void evaluate(runtime_context& context) const = 0; ... }; class lvalue_expression: public virtual expression { ... public: virtual lvalue evaluate_lvalue(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_lvalue(context); } ... }; using lvalue_expression_ptr = std::unique_ptr<lvalue_expression>; class number_expression: public virtual expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr<number_expression>; class lnumber_expression: public lvalue_expression, public number_expression { ... public: virtual lnumber evaluate_lnumber(runtime_context& context) const = 0; lvalue evaluate_lvalue(runtime_context& context) const override { return evaluate_lnumber(context); } number evaluate_number(runtime_context& context) const override { return evaluate_lnumber(context)->value; } void evaluate(runtime_context& context) const override { return evaluate_lnumber(context); } ... }; using lnumber_expression_ptr = std::unique_ptr<lnumber_expression>;
Bu sadece hiyerarşinin bir parçasıdır (sadece sayılar için) ve biz zaten elmas şekli problemleriyle karşılaştık (aynı temel sınıfa sahip iki sınıfı miras alan sınıf).
Neyse ki, C++, işaretçiyi devralınan sınıfta tutarak temel sınıftan devralma yeteneği veren sanal miras sunar. Bu nedenle, B ve C sınıfları A'dan sanal olarak miras alırsa ve D sınıfı B ve C'den miras alırsa, D'de A'nın yalnızca bir kopyası olacaktır.
Yine de bu durumda ödememiz gereken bir takım cezalar var - performans ve A'dan aşağı inememe, bunlardan birkaçı - ama bu yine de sanal mirası ilk kez kullanmam için bir fırsat gibi görünüyordu. benim hayatım.
Şimdi, toplama ifadesinin uygulanması daha doğal görünecek:
class add_expression: public number_expression { private: number_expression_ptr _expr1; number_expression_ptr _expr2; public: ... number evaluate_number(runtime_context& context) const override{ return _expr1->evaluate_number(context) + _expr2->evaluate_number(context); } ... };
Sözdizimi açısından, istenecek başka bir şey yok ve bu olabildiğince doğal. Ancak, iç ifadelerden herhangi biri bir değer sayı ifadesiyse, bunu değerlendirmek için iki sanal işlev çağrısı gerekir. Mükemmel değil ama korkunç da değil.
Bu karışıma dizeleri ekleyelim ve bizi nereye götürdüğünü görelim:
class string_expression: public virtual expression { ... public: virtual string evaluate_string(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_string(context); } ... }; using string_expression_ptr = std::unique_ptr<string_expression>;
Sayıların dizgelere dönüştürülebilir olmasını istediğimiz için, string_expression
number_expression
devralmamız gerekir.
class number_expression: public string_expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; string evaluate_string(runtime_context& context) const override { return tostring(evaluate_number(context)); } void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr<number_expression>;
Bundan kurtulduk, ancak sanal evaluate
yöntemini yeniden geçersiz kılmalıyız, yoksa sayıdan dizeye gereksiz dönüşüm nedeniyle ciddi performans sorunlarıyla karşı karşıya kalacağız.
Dolayısıyla, işler açıkça çirkinleşiyor ve tasarımımız onları zar zor ayakta tutuyor çünkü birbirine dönüştürülmesi gereken iki tür ifademiz yok (her iki şekilde). Eğer durum buysa veya herhangi bir döngüsel dönüşüm yapmaya çalışsak, hiyerarşimiz bunu kaldıramazdı. Ne de olsa hiyerarşi, daha zayıf olan bir ilişkiye dönüştürülebilir değil, bir ilişki olduğunu yansıtmalıdır.
Bütün bu başarısız girişimler beni karmaşık ama -bence- doğru bir tasarıma götürdü. İlk olarak, tek bir temel sınıfa sahip olmak bizim için çok önemli değil. Void olarak değerlendirilecek ifade sınıfına ihtiyacımız var, ancak derleme zamanında void ifadeleri ile başka türden ifadeler arasında ayrım yapabilirsek, çalışma zamanında bunlar arasında dönüştürmeye gerek yoktur. Bu nedenle, temel sınıfı, ifadenin dönüş türüyle parametrelendireceğiz.
İşte o sınıfın tam uygulaması:
template <typename R> class expression { expression(const expression&) = delete; void operator=(const expression&) = delete; protected: expression() = default; public: using ptr = std::unique_ptr<const expression>; virtual R evaluate(runtime_context& context) const = 0; virtual ~expression() = default; };
Her ifade değerlendirmesi için yalnızca bir sanal işlev çağrımız olacak (tabii ki, bunu özyinelemeli olarak çağırmamız gerekecek) ve ikili koda derleme yapmadığımız için oldukça iyi bir sonuç. Yapılması gereken tek şey, izin verildiğinde türler arasında dönüşüm yapmaktır.
Bunu başarmak için, her ifadeyi dönüş türüyle parametrelendireceğiz ve onu karşılık gelen temel sınıftan miras alacağız. Ardından, evaluate
fonksiyonunda, değerlendirme sonucunu o fonksiyonun dönüş değerine çevireceğiz.
Örneğin, bu bizim ekleme ifademizdir:
template <typename R> class add_expression: public expression<R> { ... R evaluate(runtime_context& context) const override{ return convert<R>( _expr1->evaluate(context) + _expr2->evaluate(context) ); } ... };
“Dönüştür” işlevini yazmak için bazı altyapılara ihtiyacımız var:
template<class V, typename T> struct is_boxed { static const bool value = false; }; template<typename T> struct is_boxed<std::shared_ptr<variable_impl<T> >, T> { static const bool value = true; }; string convert_to_string(number n) { std::string str if (n == int(n)) { str = std::to_string(int(n)); } else { str = std::to_string(n); } return std::make_shared<std::string>(std::move(str)); } string convert_to_string(const lnumber& v) { return convert_to_string(v->value); }
is_boxed
yapısı, ilk parametrenin ikinci türle parametrelenmiş variable_impl
için paylaşılan bir işaretçi olması durumunda (ve yalnızca) doğru olarak değerlendirilen bir iç sabite, value
sahip bir tür özelliğidir.
convert
işlevinin uygulanması C++'ın daha eski sürümlerinde bile mümkün olabilir, ancak C++17'de derleme zamanında durumu değerlendiren if constexpr
adlı çok kullanışlı bir ifade vardır. false
olarak değerlendirilirse, derleme zamanı hatasına neden olsa bile bloğu tamamen bırakır. Aksi takdirde, else
bloğunu düşürür.

template<typename To, typename From> auto convert(From&& from) { if constexpr(std::is_convertible<From, To>::value) { return std::forward<From>(from); } else if constexpr(is_boxed<From, To>::value) { return unbox(std::forward<From>(from)); } else if constexpr(std::is_same<To, string>::value) { return convert_to_string(from); } else { static_assert(std::is_void<To>::value); } }
Bu işlevi okumaya çalışın:
- C++ 'da dönüştürülebilirse dönüştürün (bu,
variable_impl
işaretçi yukarı yayını içindir). - Kutuluysa kutusundan çıkarın.
- Hedef türü dize ise dizeye dönüştürün.
- Hiçbir şey yapmayın ve hedefin geçersiz olup olmadığını kontrol edin.
Bence bu, SFINAE'ye dayalı eski sözdiziminden çok daha okunabilir.
İfade türlerine kısa bir genel bakış sunacağım ve makul bir şekilde kısa tutmak için bazı teknik ayrıntıları atlayacağım.
Bir ifade ağacında üç tür yaprak ifadesi vardır:
- Genel değişken ifadesi
- Yerel değişken ifadesi
- Sabit ifade
template<typename R, typename T> class global_variable_expression: public expression<R> { private: int _idx; public: global_variable_expression(int idx) : _idx(idx) { } R evaluate(runtime_context& context) const override { return convert<R>( context.global(_idx) ->template static_pointer_downcast<T>() ); } };
Dönüş tipi dışında, değişken tipi ile de parametrelenir. Yerel değişkenler benzer şekilde ele alınır ve bu, sabitlerin sınıfıdır:
template<typename R, typename T> class constant_expression: public expression<R> { private: T _c; public: constant_expression(T c) : _c(std::move(c)) { } R evaluate(runtime_context& context) const override { return convert<R>(_c); } };
Bu durumda, sabiti hemen yapıcıda dönüştürürüz.
Bu, ifadelerimizin çoğu için temel sınıf olarak kullanılır:
template<class O, typename R, typename... Ts> class generic_expression: public expression<R> { private: std::tuple<typename expression<Ts>::ptr...> _exprs; template<typename... Exprs> R evaluate_tuple( runtime_context& context, const Exprs&... exprs ) const { return convert<R>(O()( std::move(exprs->evaluate(context))...) ); } public: generic_expression(typename expression<Ts>::ptr... exprs) : _exprs(std::move(exprs)...) { } R evaluate(runtime_context& context) const override { return std::apply( [&](const auto&... exprs){ return this->evaluate_tuple(context, exprs...); }, _exprs ); } };
İlk argüman, somutlaştırılacak ve değerlendirme için çağrılacak olan functor türüdür. Türlerin geri kalanı, alt ifadelerin dönüş türleridir.
Standart kodu azaltmak için üç makro tanımlarız:
#define UNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1> \ auto operator()(T1 t1) {\ code;\ }\ };\ template<typename R, typename T1>\ using name##_expression = generic_expression<name##_op, R, T1>; #define BINARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2>\ auto operator()(T1 t1, T2 t2) {\ code;\ }\ };\ template<typename R, typename T1, typename T2>\ using name##_expression = generic_expression<name##_op, R, T1, T2>; #define TERNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2, typename T3>\ auto operator()(T1 t1, T2 t2, T3 t3) {\ code;\ }\ };\ template<typename R, typename T1, typename T2, typename T3>\ using name##_expression = generic_expression<name##_op, R, T1, T2, T3>;
operator()
öğesinin genellikle olması gerekmese de bir şablon olarak tanımlandığına dikkat edin. Argüman türlerini makro argümanlar olarak sağlamak yerine tüm ifadeleri aynı şekilde tanımlamak daha kolaydır.
Artık ifadelerin çoğunu tanımlayabiliriz. Örneğin, bu /=
için tanımdır:
BINARY_EXPRESSION(div_assign, t1->value /= t2; return t1; );
Bu makroları kullanarak hemen hemen tüm ifadeleri tanımlayabiliriz. İstisnalar, bağımsız değişkenlerin değerlendirme sırasını (mantıksal &&
ve ||
, üçlü ( ?
) ve virgül ( ,
) operatörü), dizi dizini, işlev çağrısı ve parametreyi işleve iletmek için param_expression
operatörlerdir. değere göre.
Bunların uygulanmasında karmaşık bir şey yoktur. İşlev çağrısı uygulaması en karmaşık olanıdır, bu yüzden burada açıklayacağım:
template<typename R, typename T> class call_expression: public expression<R>{ private: expression<function>::ptr _fexpr; std::vector<expression<lvalue>::ptr> _exprs; public: call_expression( expression<function>::ptr fexpr, std::vector<expression<lvalue>::ptr> exprs ): _fexpr(std::move(fexpr)), _exprs(std::move(exprs)) { } R evaluate(runtime_context& context) const override { std::vector<variable_ptr> params; params.reserve(_exprs.size()); for (size_t i = 0; i < _exprs.size(); ++i) { params.push_back(_exprs[i]->evaluate(context)); } function f = _fexpr->evaluate(context); for (size_t i = params.size(); i > 0; --i) { context.push(std::move(params[i-1])); } context.call(); f(context); if constexpr (std::is_same<R, void>::value) { context.end_function(_exprs.size()); } else { return convert<R>( context.end_function( _exprs.size() )->template static_pointer_downcast<T>() ); } } };
Değerlendirilen tüm argümanları yığınına iterek ve call
işlevini çağırarak runtime_context
hazırlar. Daha sonra değerlendirilen ilk argümanı (fonksiyonun kendisidir) çağırır ve end_function
yönteminin dönüş değerini döndürür. if constexpr
sözdiziminin kullanımını burada da görebiliriz. Bizi void
döndüren işlevler için tüm sınıf için uzmanlık yazmaktan kurtarır.
Artık, çalışma zamanı sırasında kullanılabilen ifadelerle ilgili her şeye sahibiz. Geriye kalan tek şey, ayrıştırılmış ifade ağacından (önceki blog gönderisinde açıklanan) ifade ağacına dönüşümdür.
İfade Oluşturucu
Karışıklığı önlemek için dil geliştirme döngümüzün farklı aşamalarını adlandıralım:

- Meta derleme zamanı: C++ derleyicisinin çalıştığı aşama
- Derleme zamanı: Stork derleyicisinin çalıştığı aşama
- Çalışma zamanı: Stork betiğinin çalıştığı aşama
İfade oluşturucunun sözde kodu:
function build_expression(nodeptr n, compiler_context context) { if (n is constant) { return constant_expression(n.value); } else if (n is identifier) { id_info info = context.find(n.value); if (context.is_global(info)) { return global_variable_expression(info.index); } else { return local_variable_expression(info.index); } } else { //operation switch (n->value) { case preinc: return preinc_expression( build_expression(n->child[0]) ); ... case add: return add_expression( build_expression(n->child[0]), build_expression(n->child[1]) ); ... case call: return call_expression( n->child[0], //function n->child[1], //arg0 ... n->child[k+1], //argk ); } } }
Tüm işlemleri halletmek zorunda olmanın dışında, bu basit bir algoritma gibi görünüyor.
Çalışsaydı harika olurdu ama olmuyor. Yeni başlayanlar için, işlevin dönüş türünü belirtmemiz gerekiyor ve bu, burada sabit değildir, çünkü dönüş türü, ziyaret ettiğimiz düğümün türüne bağlıdır. Düğüm türleri derleme zamanında bilinir, ancak dönüş türleri meta derleme zamanında bilinmelidir.
Bir önceki yazımda dinamik tip kontrolü yapan dillerin avantajını görmediğimden bahsetmiştim. Bu tür dillerde, yukarıda gösterilen sözde kod neredeyse tam anlamıyla uygulanabilir. Şimdi, dinamik tip dillerin avantajlarının oldukça farkındayım. En iyi anında karma.
Neyse ki, üst düzey ifadenin türünü biliyoruz - bu, derlemenin bağlamına bağlıdır, ancak türünü ifade ağacını ayrıştırmadan biliyoruz. Örneğin, for döngüsüne sahipsek:
for (expression1; expression2; expression3) ...
Birinci ve üçüncü ifadeler, değerlendirme sonuçlarıyla hiçbir şey yapmadığımız için void
bir dönüş türüne sahiptir. Ancak ikinci ifadenin bir tür number
vardır çünkü döngüyü durdurup durdurmamaya karar vermek için onu sıfırla karşılaştırıyoruz.
Düğüm işlemiyle ilgili ifadenin türünü biliyorsak, genellikle alt ifadesinin türünü belirleyecektir.
Örneğin, (ifade1 (expression1) += (expression2)
expression1
lnumber
, bu, ifade1'in de bu tipe sahip olduğu ve expression2
tip number
sahip olduğu anlamına gelir.
Ancak, (expression1) < (expression2)
her zaman tür number
vardır, ancak bunların alt ifadelerinde tür number
veya string
türü olabilir. Bu ifade durumunda, her iki düğümün de sayı olup olmadığını kontrol edeceğiz. Öyleyse, ifade1 ve expression2
expression1
expression<number>
olarak oluşturacağız. Aksi takdirde, expression<string>
.
Hesaba katmamız ve halletmemiz gereken başka bir sorun daha var.
type number
türünde bir ifade oluşturmamız gerekip gerekmediğini hayal edin. Ardından, bir birleştirme operatörüyle karşılaşırsak geçerli bir şey döndüremeyiz. Bunun olamayacağını biliyoruz, çünkü ifade ağacını oluşturduğumuzda (önceki bölümde) türleri zaten kontrol etmiştik, ancak bu, geri dönüş türüyle parametrelenmiş şablon işlevini yazamayacağımız anlamına gelir, çünkü buna bağlı olarak geçersiz dallara sahip olacaktır. bu dönüş türünde.
Bir yaklaşım, if constexpr
kullanarak işlevi dönüş türüne göre bölebilir, ancak verimsizdir çünkü aynı işlem birden fazla dalda mevcutsa, kodunu tekrarlamamız gerekir. Bu durumda ayrı fonksiyonlar yazabiliriz.
Uygulanan çözüm, işlevi düğüm türüne göre böler. Dalların her birinde, o dal tipinin fonksiyon dönüş tipine dönüştürülebilir olup olmadığını kontrol edeceğiz. Olmazsa derleyici hatasını atarız çünkü asla olmaması gerekir ama kod böyle güçlü bir iddia için fazla karışık. Bir hata yapmış olabilirim.
Dönüştürülebilirliği kontrol etmek için aşağıdaki açıklayıcı, tip-özellik yapısını kullanıyoruz:
template<typename From, typename To> struct is_convertible { static const bool value = std::is_convertible<From, To>::value || is_boxed<From, To>::value || ( std::is_same<To, string>::value && ( std::is_same<From, number>::value || std::is_same<From, lnumber>::value ) ); };
Bu bölünmeden sonra, kod neredeyse basittir. Orijinal ifade türünden oluşturmak istediğimize anlamsal olarak aktarabiliriz ve meta derleme zamanında hiçbir hata yoktur.
Ancak çok fazla ortak kod var, bu yüzden azaltmak için büyük ölçüde makrolara güvendim.
template<typename R> class expression_builder{ private: using expression_ptr = typename expression<R>::ptr; static expression_ptr build_void_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_number_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lnumber_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lstring_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_array_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_larray_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_function_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lfunction_expression( const node_ptr& np, compiler_context& context ); public: static expression_ptr build_expression( const node_ptr& np, compiler_context& context ) { return std::visit(overloaded{ [&](simple_type st){ switch (st) { case simple_type::number: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lnumber); } else { RETURN_EXPRESSION_OF_TYPE(number); } case simple_type::string: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lstring); } else { RETURN_EXPRESSION_OF_TYPE(string); } case simple_type::nothing: RETURN_EXPRESSION_OF_TYPE(void); } }, [&](const function_type& ft) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lfunction); } else { RETURN_EXPRESSION_OF_TYPE(function); } }, [&](const array_type& at) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(larray); } else { RETURN_EXPRESSION_OF_TYPE(array); } } }, *np->get_type_id()); } };
build_expression
işlevi buradaki tek genel işlevdir. Düğüm türünde std::visit
işlevini çağırır. Bu işlev, geçirilen functor'ı variant
üzerinde uygulayarak süreçte ayrıştırır. Bu konuda ve burada overloaded
yüklenen işlev hakkında daha fazla bilgi edinebilirsiniz.
RETURN_EXPRESSION_OF_TYPE
makrosu, ifade oluşturma için özel işlevleri çağırır ve dönüştürme mümkün değilse bir istisna atar:
#define RETURN_EXPRESSION_OF_TYPE(T)\ if constexpr(is_convertible<T, R>::value) {\ return build_##T##_expression(np, context);\ } else {\ throw expression_builder_error();\ return expression_ptr();\ }
İmkansız dönüştürme durumunda derleyici işlev dönüş türünü bilemediği için, else-dalındaki boş göstericiyi döndürmemiz gerekir; aksi takdirde, std::visit
, tüm aşırı yüklenmiş işlevlerin aynı dönüş türüne sahip olmasını gerektirir.
Örneğin, dönüş türü olarak string
ile ifadeler oluşturan bir işlev vardır:
static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ) { if (std::holds_alternative<std::string>(np->get_value())) { return std::make_unique<constant_expression<R, string>>( std::make_shared<std::string>( std::get<std::string>(np->get_value()) ) ); } CHECK_IDENTIFIER(lstring); switch (std::get<node_operation>(np->get_value())) { CHECK_BINARY_OPERATION(concat, string, string); CHECK_BINARY_OPERATION(comma, void, string); CHECK_TERNARY_OPERATION(ternary, number, string, string); CHECK_INDEX_OPERATION(lstring); CHECK_CALL_OPERATION(lstring); default: throw expression_builder_error(); } }
Düğümün dizeyi constant_expression
tutup tutmadığını kontrol eder ve bu durumda sabit_ifadeyi oluşturur.
Ardından, düğümün bir tanımlayıcıya sahip olup olmadığını kontrol eder ve bu durumda lstring tipinin global veya yerel değişken ifadesini döndürür. Sabit değişkenleri uygularsak bir tanımlayıcı tutabilir. Aksi takdirde, düğümün düğüm işlemini tuttuğunu varsayar ve string
döndürebilecek tüm işlemleri dener.
İşte CHECK_IDENTIFIER
ve CHECK_BINARY_OPERATION
makrolarının uygulamaları:
#define CHECK_IDENTIFIER(T1)\ if (std::holds_alternative<identifier>(np->get_value())) {\ const identifier& id = std::get<identifier>(np->get_value());\ const identifier_info* info = context.find(id.name);\ if (info->is_global()) {\ return std::make_unique<\ global_variable_expression<R, T1>\ >(info->index());\ } else {\ return std::make_unique<\ local_variable_expression<R, T1>\ >(info->index());\ }\ }
#define CHECK_BINARY_OPERATION(name, T1, T2)\ case node_operation::name:\ return expression_ptr(\ std::make_unique<name##_expression<R, T1, T2> > (\ expression_builder<T1>::build_expression(\ np->get_children()[0], context\ ),\ expression_builder<T2>::build_expression(\ np->get_children()[1], context\ )\ )\ );
CHECK_IDENTIFIER
makrosu, uygun dizine sahip bir global veya yerel değişken ifadesi oluşturmak için compiler_context
başvurmalıdır. Bu yapıdaki compiler_context
tek kullanımı budur.
CHECK_BINARY_OPERATION build_expression
CHECK_BINARY_OPERATION
görebilirsiniz.
Toplama
GitHub sayfamda kaynak kodunun tamamını alabilir, derleyebilir ve ardından ifadeleri yazıp değerlendirilen değişkenlerin sonucunu görebilirsiniz.
İnsan yaratıcılığının tüm dallarında, yazarın ürününün bir anlamda canlı olduğunu fark ettiği bir an olduğunu hayal ediyorum. Bir programlama dilinin yapımında, dilin “nefes aldığını” görebileceğiniz andır.
Bu dizinin sonraki ve son bölümünde, canlı olarak çalıştığını görmek için minimum dil özellikleri kümesinin geri kalanını uygulayacağız.