attrs icon indicating copy to clipboard operation
attrs copied to clipboard

Support evolving to a subclass (possibly any class)

Open fredsensibill opened this issue 1 year ago • 7 comments

Recently I wrote a modification to the attrs.evolve function that works like this:

@attrs.define
class Foo:
  a: int

@attrs.define
class Bar(Foo):
  b: str

foo = Foo(1)
bar = evolve_as(foo, Bar, b="2")
print(bar)
# Bar(a=1, b='2')

This was very useful for my particular use-case, which involved enriching a data object in a pipeline, so I thought that this might be a good fit for attrs.

My implementation did not require Bar to be a subclass of Foo, but I'm not sure about the design implications of that. Might be better to limit this to subclasses only.

fredsensibill avatar Jan 19 '23 17:01 fredsensibill

For reference, this is my implementation:

def evolve_as(__inst: Any, __cls: Type[_S], **changes: Any) -> _S:
    inst_cls = type(__inst)
    inst_field_names = {field.name for field in attrs.fields(inst_cls)}
    
    for cls_field in attrs.fields(__cls):
        field_name = cls_field.name
        if field_name in inst_field_names:  # get field from inst
            init = cls_field.init
            alias = cls_field.alias
            if not init:
                continue
            if alias not in changes:
                changes[alias] = getattr(__inst, field_name)
    return __cls(**changes)

Had to prefix the parameters with __ to try not to invade the target class attributes namespace

fredsensibill avatar Jan 19 '23 17:01 fredsensibill

Heh wait, so what you're doing is taking a base class and fill out the rest such that it becomes a super class?

hynek avatar Jan 25 '23 08:01 hynek

Heh wait, so what you're doing is taking a base class and fill out the rest such that it becomes a super class?

Not a super class, a subclass.

As a more concrete example, I used this in a Document Understanding system to tag text blocks:

@attrs.define
class TextBlock:
    text: str
    x: float
    y: float
    width: float
    height: float

@attrs.define
class TaggedTextBlock(TextBlock):
    tag: str

blocks: List[TextBlock] = ocr(document)
tagged_blocks = [
    evolve_as(block, TaggedTextBlock, tag=get_block_tag(block))
    for block in blocks
] 

fredsensibill avatar Jan 25 '23 15:01 fredsensibill

For well-behaved subclasses (which for attrs-generated ones should be all of em unless you did init=False and wrote a "bad" subclass __init__ yourself), isn't your example equivalent to:

tagged_blocks = [
    TaggedTextBlock(**attrs.asdict(block), tag=get_block_tag(block))
    for block in blocks
] 

(i.e. in general, calling attrs.asdict and adding whatever additional attributes in the subclass you have)?

Julian avatar Jan 26 '23 15:01 Julian

For well-behaved subclasses (which for attrs-generated ones should be all of em unless you did init=False and wrote a "bad" subclass init yourself), isn't your example equivalent to:

Well sorta... Would you call overwriting superclass fields definitions "well-behaved"? Take overwriting with an alias as an example:

@attrs.define
class Foo:
    a: int

@attrs.define
class Bar(Foo):
    a: int = attrs.field(alias="b")

foo = Foo(1)
bar = evolve_as(foo, Bar)  # Ok
bar = Bar(**attrs.asdict(foo))
# TypeError: __init__() got an unexpected keyword argument 'a'

This also fails if you overwrite a single field's init option as False. I get that overwriting fields definitions might be an edge case that you don't really want to cover though. Generally attrs.asdict should be enough.

I think it's also relevant to say that evolve_as is more performant than creating an intermediate dict with asdict. It's about 30% faster on my laptop for small objects (2 int fields), and about 70% faster for bigger objects (10 int fields).

fredsensibill avatar Jan 26 '23 16:01 fredsensibill

Well sorta... Would you call overwriting superclass fields definitions "well-behaved"?

I wouldn't personally, because it means the subclass has a different signature than the superclass and that breaks with multiple inheritance and/or violates Liskov (though IIRC there's some back and forth about whether people agree to the latter indeed violating LSP, and considering I don't personally generally use inheritance, I guess I shouldn't feel too strongly :P) -- but yeah fair enough if that's the case you have in mind that at least clarifies the ask, just wanted to see if I followed, obviously Hynek or some of the library maintainers will have opinions :D

Julian avatar Jan 26 '23 16:01 Julian

Not sure what you mean by "that breaks with multiple inheritance"...

@attrs.define(slots=False)
class Foo:
    a: int

@attrs.define(slots=False)
class Bar:
    b: int

@attrs.define(slots=False)
class Baz(Foo, Bar):
    a: int = attrs.field(alias="c")

foo = Foo(1)
baz = evolve_as(foo, Baz, b=2)  # OK

Maybe getting out of topic, but I think LSP is almost too easy to break if you consider "class signature" as the invariant property. Just add a new field without a default value and you can't instantiate the class in the same way, breaking LSP.

fredsensibill avatar Jan 26 '23 18:01 fredsensibill