0

For example,

# low level file Foo.py
class Foo:
   def __init__(...):
      # a class that is difficult to construct, as it is connected to configuration
      # files and schemas

      # it is also useful to import and use early prior to the interpretation of other 
      # classes since it is a configured entity
# resource file resource.py
cfg = load_config('config.json')
foo = Foo.from_config(cfg)
# enum file TheFoos.py
from resource import foo

class TheFoos:

    A = 'a'
    B = 'b'

    _ctx = foo.ctx
# descendant class with useful methods in UpgradedFoo.py
from Foo import Foo

class UpgradedFoo(Foo):

    def do_fabble(self, ...):
        pass

Finally, the crux of the question:

# interface for end user interface.py
from resources import foo
from UpgradedFoo import UpgradedFoo

upgraded_foo = UpgradedFoo(foo)  # the pythonic pseudo code of the C++ way

And in a large variety of downstream files:

from deep.internal.interface import upgraded_foo

upgraded_foo.do_fabble(...)

And a large variety of legacy files:

from deep.internal.TheFoos import TheFoos

TheFoos.do_something_configured_just_once_in_new_code()

So winding back around to the impasse:

upgraded_foo = UpgradedFoo(foo) # the c++-ish way

# but what is the python way?

But what is the way this is done in python?

I have tried: from typing import cast; cast(UpgradedFoo, foo), but that did not work. I have also tried researching the question, but this seems to be a sticking point between the experts and the noobs, and there is no real good answer that actually confronts and addresses the issue directly.

The idea is to get this functioning so there are no circular imports and code parameters are all based on a single configuration without inducing tremendous change propagation in the code.

Please note that while the above is not some sort of ideal architecture, it is along the path to an ideal architecture having integrated old work from many authors with new work.


Motivation aside, the need here is to upcast the base class instance to the inherited class instance.


Note on the "C++ way" (all details):

Suppose a base class and a derived class exist, and that the "data" held by the base class is identical to the derived class -- there is no "extra" sort of data in the derived class relative to the base class.

Then a pointer can be generated and cast to the derived class for derived class functionality. The pointer only "cares" about the instance data and where the binary functions are. The actual bundle of functions associated by the compiler with that data can be upgraded to derived class context w/ the cast operation.

#include<iostream>

class A {
public:
  int foo;
  A(int x): foo(x){};
};

class B : public A {
  public: int bar() {return this-> foo * 2;};
};

int main() {
  A a = A(1);

  A* astar = &a;
  B* bstar = (B*) &a;
  B b = *bstar;

  // symbolic cast
  B b2 = *(B*)&a;
  

  std::cout << astar << std::endl;
  std::cout << bstar << std::endl;
  std::cout << bstar->bar() << std::endl;
  std::cout << b.bar() << std::endl;
  std::cout << b2.bar() << std::endl;
}
// output:
0x#####
0x#####
2
2
2
Chris
  • 28,822
  • 27
  • 83
  • 158
  • If `UpgradedFoo` had an `__init__()` method that accepted an old-style `Foo` instance (perhaps it just copied over the relevant fields), then what you have described as "the C++ way" is exactly how you would do it in Python. – jasonharper Feb 16 '22 at 21:16
  • @jasonharper ideally it does not define a custom init because that would be fault intolerant. Any change to the root class initialization and data content would require me to *remember* to change the descendant `__init__()` function -- thus ruling out **the implicit gains you are supposed to get** from OOP, and creating a tripwire in the code. – Chris Feb 16 '22 at 21:17
  • How did `UpgradedFoo(foo)` work in your hypothetical C++ code? C++ doesn't provide any automated mechanism for constructing a subclass from a superclass, so the person who wrote `UpgradedFoo` had to do the exact same thing in C++. – Silvio Mayolo Feb 16 '22 at 21:19
  • @SilvioMayolo not if it is a bare class with no data tack-ons or constructor. I can simply cast it or, with copy constructor defined in the root class (localizing control to the root class), I can copy construct it. – Chris Feb 16 '22 at 21:20
  • This is a weird question. You're casting a superclass to a subclass?! Isn't that a code smell? A `Foo` *isn't* a `UpgradedFoo`, so why do you want to pretend that it is? If the goal is just to call the `do_fabble` method, then why not just define `do_fabble` as a function rather than a method? – Aran-Fey Feb 16 '22 at 21:20
  • @Aran-Fey thus propagating change throughout the code. Please respect the constraints of the question. – Chris Feb 16 '22 at 21:21
  • @Aran-Fey the idea is to avoid changing anything except code in one spot. – Chris Feb 16 '22 at 21:22
  • @Chris [That's not how C++ works](https://tio.run/##LcsxDoAgEETRfk8xJRReAGIBN4FNNCQIJGhlPDsu0fJP3nBry848BnEOvcPhpnbFnNgQkMqJrVZLj6UfeBh8YNq5T3SEVJSWL2QNWOGUthIeUcKrIPXQGC8). I suspect you're misremembering something from an old codebase. – Silvio Mayolo Feb 16 '22 at 21:23
  • So bad decisions were made in the past and now you're stuck with them. Got it. Not sure if this helps, but you can call the method like `UpgradedFoo.do_fabble(foo)`. – Aran-Fey Feb 16 '22 at 21:23
  • @SilvioMayolo https://stackoverflow.com/questions/5313322/c-cast-to-derived-class. perhaps it is OK to operate at a slightly higher level. There are a lot of ways to handle this situation in C++, albeit they all involve legwork that, yeah, will be specific to code. – Chris Feb 16 '22 at 21:24
  • If you want to do this right, then take Jason's advice and write a proper constructor. If all you want is to hack it to work with `do_fabble`, then some chicanery like `original_foo.do_fabble = lambda: UpgradedFoo.do_fabble(original_foo)` will do. But that's not good coding style; that's a hack to cover up another hack. My advice: Do it right, or the next guy a year down the line will be cursing your name when he posts an SO question about getting around *this* nasty hack. – Silvio Mayolo Feb 16 '22 at 21:26
  • @Aran-Fey I am not sure that "bad decisions" is the correct frame. More like TD incremental development involving the work of several independent authors requires incremental techniques. Sure, it would be great to make 1000 changes without mistakes, but in practical terms one move to a working situation followed by a refactor is better. – Chris Feb 16 '22 at 21:28
  • @Aran-Fey naturally refactoring won't be a priority. – Chris Feb 16 '22 at 21:29
  • @SilvioMayolo normative opinion aside, let's take a step back: in your opinion this is not possible in Python3 and I'll have to explicitly declare things. Got it. – Chris Feb 16 '22 at 21:30
  • I just got an idea that I would normally never propose, but since you're already waist-deep in gross hacks, I might as well... Just define `UpgradedFoo.__init__` as `def __init__(self, foo): self.__dict__ = foo.__dict__`. That'll give you two objects that share all of their attributes. – Aran-Fey Feb 16 '22 at 21:33
  • Isn't it possible to change `resource.py` to instantiate `UpgradedFoo` instead of `Foo`? I understand it will not work in all cases, so just a question. – buran Feb 16 '22 at 21:38
  • @buran that was my first idea, and agreed. But, in this project due to the initialization and import ordering through `.module.__init__.py`, I had to move any imports that rely on `Foo` instead of `DeepFoo` to the first level after `Foo` is initialized and loaded in an `__init__.py` to avoid a circular dependency. – Chris Feb 16 '22 at 21:53
  • @Aran-Fey can't do that in python because there is only the one opportunity to construct. If I override the super constructor, I don't get it when it is needed for the inherited class. Also, I'd probably deep copy it -- and at that point it isn't a dirty hack, since now we've defined a dynamic cast copy constructor. The real drawback is that, yeah, I've lost the root constructor. What would make it a hack is any bespoke solution outside the "language" that must be changed in multiple places when the root changes. In my opinion. – Chris Feb 16 '22 at 21:56

1 Answers1

0

So I suppose this is a philosophical choice in python, but it looks like a push model of change propagation is deployed, and there is no way out.

If there is a base class, and a descendant class pulls that base class content into it:

class Foo:
   ...

class DeepFoo(Foo):
   ...

Then if DeepFoo is to be wrapped around an initialized Foo in some context, the DeepFoo Must know how to initialize itself from a Foo by reversing Foo's data dictionary to Foo's input contract -- or lose the root class constructor:

class DeepFoo(Foo):
   def __init__(self, foo: Foo):  # overriding the root constructor
       self.__dict__ = deepcopy(foo.__dict__)

Further, if a DeepFoo.from_foo(...) is possible and defined, the Foo cannot inform the DeepFoo about its own changes -- the DeepFoo is responsible for discovering and keeping up with the Foo in order to have the data necessary for all inherited Foo functions to function properly when DeepFoo is wrapped around a pre-existing Foo.

Although python is a dynamic language, dynamic casting is not available, and one cannot simply "own" the data contents of another class without actually going through the construction process.

That means that, when this point is reached in code and it is desirable to split a class for whatever reason -- such as a difficult reverse engineering position -- into a base and descendant, it may be necessary to shift to a nested, dormant wrapper model that, while similar to the first copy method, at least is what it claims to be:

class Wrapper:
    def __init__(self, item: Any):
        self.__item = item
    
        def __getattr__(self, item: str) -> None:
            return eval(f"self.item.{item}")

class DeepFoo(Wrapper):
    def __init__(self, foo: Foo):
        self.super().__init__(foo)
    
    def do_fabble(self, args):
        # use self.item here!

And, in this scenario there is a downside in that the IDE intellisense is now disconnected such that the Foo methods DeepFoo has access to are private (or dormant) for the purposes of intellisense, but they are there during dynamic evaluation. However, the wrapper here can serve as a placeholder that is not misleading as functions are used to replace the DeepFoo.do_fabble in downstream code.


A third alternative that I thought of which minimizes any "hackery" and keystrokes is to keep the DeepFoo name around, but make it contain only @staticmethods

# So

class DeepFoo(Foo):
   def do_fabble(self, ...):
      pass


# becomes
class DeepFoo:
   @staticmethod
   def do_fabble(self: Foo, ...):
       ... 

And all downstream changes to dependencies on DeepFoo still propagate, but are relatively casual and only propagate once.


The last alternative got me past all import issues, got my legacy code tethered to my new configuration file, and prevented a large refactor without generating a lot of labor or code that is difficult to understand.

Chris
  • 28,822
  • 27
  • 83
  • 158