/* LazyInit(Test) - verify a mechanism to install a self-initialising functor Copyright (C) 2023, Hermann Vosseler   **Lumiera** is free software; you can redistribute it and/or modify it   under the terms of the GNU General Public License as published by the   Free Software Foundation; either version 2 of the License, or (at your   option) any later version. See the file COPYING for further details. * *****************************************************************/ /** @file lazy-init-test.cpp ** unit test \ref LazyInit_test */ #include "lib/test/run.hpp" #include "lib/test/test-helper.hpp" #include "lib/lazy-init.hpp" #include "lib/meta/util.hpp" #include "lib/util.hpp" #include namespace lib { namespace test{ using util::isSameObject; using lib::meta::isFunMember; using lib::meta::disable_if_self; using err::LUMIERA_ERROR_LIFECYCLE; using std::make_unique; /***********************************************************************************//** * @test Verify a mix-in to allow for lazy initialisation of complex infrastructure * tied to a std::function; the intention is to have a »trap« hidden in the * function itself to trigger on first use and perform the one-time * initialisation, then finally lock the object at a fixed place. * @see lazy-init.hpp * @see lib::RandomDraw */ class LazyInit_test : public Test { void run (Arg) { seedRand(); verify_trojanLambda(); verify_inlineStorage(); verify_TargetRelocation(); verify_triggerMechanism(); verify_lazyInitialisation(); verify_complexUsageWithCopy(); } /** @test verify construction of the »trap« front-end eventually to trigger initialisation * - this test does not involve any std::function, rather a heap-allocated copy of a λ * # the _target function_ finally to be invoked performs a verifiable computation * # the _delegate_ receives an memory location and returns a reference to the target * # the generated _»trojan λ«_ captures its own address, invokes the delegate, * retrieves a reference to a target functor, and finally invokes this with actual arguments. * @remark the purpose of this convoluted scheme is for the _delegate to perform initialisation,_ * taking into account the current memory location „sniffed“ by the trojan. */ void verify_trojanLambda() { size_t beacon; auto fun = [&](uint challenge){ return beacon+challenge; }; using Sig = size_t(uint); CHECK (isFunMember (&fun)); beacon = rani(); uint c = beacon % 42; // verify we can invoke the target function CHECK (beacon+c == fun(c)); // verify we can also invoke the target function through a reference using FunType = decltype(fun); FunType& funRef = fun; CHECK (beacon+c == funRef(c)); // construct delegate function exposing the expected behaviour; // additionally this function captures the passed-in address. RawAddr location{nullptr}; auto delegate = [&](RawAddr adr) -> FunType& { location = adr; return fun; }; using Delegate = decltype(delegate); auto delP = make_unique (delegate); // verify the heap-allocated copy of the delegate behaves as expected location = nullptr; CHECK (beacon+c == (*delP)(this)(c)); CHECK (location == this); // now (finally) build the »trap function«... auto trojanLambda = TrojanFun::generateTrap (delP.get()); CHECK (sizeof(trojanLambda) == sizeof(size_t)); // on invocation... // - it captures its current location // - passes this to the delegate // - invokes the target function returned from the delegate CHECK (beacon+c == trojanLambda(c)); CHECK (location == &trojanLambda); // repeat same with a copy, and changed beacon value auto trojanClone = trojanLambda; beacon = rani(); c = beacon % 55; CHECK (beacon+c == trojanClone(c)); CHECK (location == &trojanClone); CHECK (beacon+c == trojanLambda(c)); CHECK (location == &trojanLambda); } /** @test verify that std::function indeed stores a simple functor inline. * @remark The implementation of LazyInit relies crucially on a known optimisation * in the standard library ─ which unfortunately is not guaranteed by the standard: * Typically, std::function will apply _small object optimisation_ to place a very * small functor directly into the wrapper, if the payload has a trivial copy-ctor. * `Libstdc++` is known to be rather restrictive, while other implementations trade * increased storage size of std::function against more optimisation possibilities. * LazyInit exploits this optimisation to „spy“ about the current object location, * allowing to execute the lazy initialisation on first use, without further help * by client code. This trickery seems to be the only way, since λ-capture by reference * is broken after copying or moving the host object (typically required for DSL use). * In case this turns out to be fragile, LazyInit should become a "LateInit" and needs * help by the client or the user to trigger initialisation; alternatively the DSL * could be split off into a separate builder object distinct from RandomDraw. */ void verify_inlineStorage() { // char payload[24];// ◁─────────────────────────────── use this to make the test fail.... const char* payload = "I am innocent as a lamb"; auto lambda = [payload]{ return RawAddr(&payload); }; RawAddr location = lambda(); CHECK (location == &lambda); std::function funWrap{lambda}; CHECK (funWrap); CHECK (not isSameObject (funWrap, lambda)); location = funWrap(); CHECK (util::isCloseBy (location, funWrap)); // if »small object optimisation« was used, // the lambda will be copied directly into the std:function; // otherwise it will be heap allocated and this test fails. // for context: these are considered "close by", // since both are sitting right here in the same stack frame CHECK (util::isCloseBy (funWrap, lambda)); } /** @test verify navigating an object structure * by applying known offsets consecutively * from a starting point within an remote instance * @remark in the real usage scenario, we know _only_ the offset * and attempt to find home without knowing the layout. */ void verify_TargetRelocation() { struct Nested { int unrelated{rani()}; int anchor{rani()}; }; struct Demo { Nested nested; virtual ~Demo(){ }; virtual RawAddr peek() { return &nested.anchor; } }; // find out generic offset... const ptrdiff_t offNested = []{ Nested probe; return captureRawAddrOffset(&probe, &probe.anchor); }(); Demo here; // find out actual offset in existing object const ptrdiff_t offBase = captureRawAddrOffset(&here, &here.nested); CHECK (offBase > 0); CHECK (offNested > 0); // create a copy far far away... auto farAway = make_unique (here); // reconstruct base address from starting point RawAddr startPoint = farAway->peek(); Nested* farNested = relocate(startPoint, -offNested); CHECK (here.nested.unrelated == farNested->unrelated); Demo* farSelf = relocate (farNested, -offBase); CHECK (here.nested.anchor == farSelf->nested.anchor); CHECK (isSameObject (*farSelf, *farAway)); } /** @test demonstrate the trigger mechanism in isolation */ void verify_triggerMechanism() { using Fun = std::function; Fun theFun; CHECK (not theFun); int report{0}; auto delegate = [&report](RawAddr insideFun) -> Fun& { auto realFun = [&report](int num) { report += num; return num + 23.55f; }; Fun& target = *relocate(insideFun, -FUNCTOR_PAYLOAD_OFFSET); report = -42; // as proof that the init-delegate was invoked target = realFun; return target; }; CHECK (not theFun); // install the init-»trap« theFun = TrojanFun::generateTrap (&delegate); CHECK (theFun); CHECK (0 == report); // invoke function int feed{1 + rani (100)}; float res = theFun (feed); // delegate *and* realFun were invoked CHECK (feed == report + 42); CHECK (res = feed -42 +23.55f); // again... report = 0; feed = -1-rani(20); res = theFun (feed); // this time the delegate was *not* invoked, // only the installed realFun CHECK (feed == report); CHECK (res = feed + 23.55f); } /** @test demonstrate a basic usage scenario */ void verify_lazyInitialisation() { using Fun = std::function; using Lazy = LazyInit; bool init{false}; uint invoked{0}; Lazy funny{funny, [&](Lazy* self) { Fun& thisFun = static_cast (*self); thisFun = [&invoked](int num) { ++invoked; return num * 0.555f; }; init = true; }}; CHECK (not invoked); CHECK (not init); CHECK (funny); int feed = 1 + rani(99); CHECK (feed*0.555f == funny(feed)); CHECK (1 == invoked); CHECK (init); } /** elaborate setup used for integration test */ struct LazyDemo : LazyInit<> { using Fun = std::function; int seed{0}; Fun fun; // ◁────────────────────────────────── this will be initialised lazily.... template auto buildInit (FUN&& fun2install) { return [theFun = forward (fun2install)] (LazyDemo* self) { // this runs when init is actually performed.... CHECK (self); if (self->fun) // chain-up behind existing function self->fun = [self, prevFun=self->fun, nextFun=theFun] (int i) { return nextFun (prevFun (i)); }; else // build new function chain, inject seed from object self->fun = [self, newFun=theFun] (int i) { return newFun (i + self->seed); // Note: binding to actual instance location }; }; } LazyDemo() : LazyInit{MarkDisabled()} , fun{} { installInitialiser(fun, buildInit([](int){ return 0; })); } // prevent this ctor from shadowing the copy ctors //////TICKET #963 template> LazyDemo (FUN&& someFun) : LazyInit{MarkDisabled()} , fun{} { installInitialiser(fun, buildInit (forward (someFun))); } template LazyDemo&& attach (FUN&& someFun) { installInitialiser(fun, buildInit (forward (someFun))); return move(*this); } }; /** * @test use an elaborately constructed example to cover more corner cases * - the function to manage and initialise lazily is _a member_ of the _derived class_ * - the initialisation routine _adapts_ this function and links it with the current * object location; thus, invoking this function on a copy would crash / corrupt memory. * - however, as long as initialisation has not been triggered, LazyDemo instances can be * copied; they may even be assigned to existing instances, overwriting their state. * - a second given function will be chained behind the first one; this happens immediately * if the first function was already invoked (and this initialised) * - but when however both functions are attached immediately, prior to invocation, * then an elaborate chain of initialisers is setup behind the scenes and played back * in definition order once lazy initialisation is triggered * - all the intermediary state is safe to copy and move and fork * @remark 11/2023 memory allocations were verified using lib::test::Tracker and the EventLog */ void verify_complexUsageWithCopy() { LazyDemo dd; CHECK (not dd.isInit()); // not initialised, since function was not invoked yet CHECK (dd.fun); // the functor is not empty anymore, since the »trap« was installed dd.seed = 2; CHECK (0 == dd.fun(22)); // d1 was default initialised and thus got the "return 0" function CHECK (dd.isInit()); // first invocation also triggered the init-routine // is »engaged« after init and rejects move / copy VERIFY_ERROR (LIFECYCLE, LazyDemo dx{move(dd)} ); dd = LazyDemo{[](int i) // assign a fresh copy (discarding any state in d1) { return i + 1; // using a "return i+1" function }}; CHECK (not dd.isInit()); CHECK (dd.seed == 0); // assignment indeed erased any existing settings (seed≔2) CHECK (dd.fun); CHECK (23 == dd.fun(22)); // new function was tied in (while also referring to self->seed) CHECK (dd.isInit()); dd.seed = 3; // set the seed CHECK (26 == dd.fun(22)); // seed value is picked up dynamically VERIFY_ERROR (LIFECYCLE, LazyDemo dx{move(dd)} ); // attach a further function, to be chained-up dd.attach([](int i) { return i / 2; }); CHECK (dd.isInit()); CHECK (dd.seed == 3); CHECK (12 == dd.fun(21)); // 21+3+1=25 / 2 CHECK (13 == dd.fun(22)); CHECK (13 == dd.fun(23)); dd.seed++; CHECK (14 == dd.fun(23)); // 23+4+1=28 / 2 CHECK (14 == dd.fun(24)); CHECK (15 == dd.fun(25)); // ...use exactly the same configuration, // but applied in one shot -> chained lazy-Init dd = LazyDemo{[](int i){return i+1; }} .attach([](int i){return i/2; }); dd.seed = 3; CHECK (not dd.isInit()); CHECK (dd.seed == 3); CHECK (dd.fun); CHECK (12 == dd.fun(21)); CHECK (13 == dd.fun(22)); CHECK (13 == dd.fun(23)); dd.seed++; CHECK (14 == dd.fun(23)); CHECK (14 == dd.fun(24)); CHECK (15 == dd.fun(25)); // create a nested graph of chained pending init dd = LazyDemo{[](int i){return i+1; }}; LazyDemo d1{dd}; LazyDemo d2{move(dd)}; d2.seed = 3; d2.attach ([](int i){return i/2; }); LazyDemo d3{d2}; d2.attach ([](int i){return i-1; }); // dd was left in defunct state by the move, and thus is locked CHECK (not dd.fun); CHECK (dd.isInit()); VERIFY_ERROR (LIFECYCLE, LazyDemo dx{move(dd)} ); // this can be amended by assigning another instance not yet engaged dd = d2; d2.seed = 5; std::swap (d2,d3); std::swap (d3,d1); // confused?? ;-) CHECK (not dd.isInit() and dd.seed == 3); // Seed≡3 {i+1} ⟶ {i/2} ⟶ {i-1} CHECK (not d1.isInit() and d1.seed == 5); // Seed≡5 {i+1} ⟶ {i/2} ⟶ {i-1} CHECK (not d2.isInit() and d2.seed == 3); // Seed≡3 {i+1} ⟶ {i/2} CHECK (not d3.isInit() and d3.seed == 0); // Seed≡0 {i+1} CHECK (12 == dd.fun(23)); // 23+3 +1 = 27/2 = 13 -1 = 12 CHECK (13 == d1.fun(23)); // 23+5 +1 = 29/2 = 14 -1 = 13 CHECK (13 == d2.fun(23)); // 23+3 +1 = 27/2 = 13 = 13 CHECK (24 == d3.fun(23)); // 23+0 +1 = 24 } }; /** Register this test class... */ LAUNCHER (LazyInit_test, "unit common"); }} // namespace lib::test