Subject: bug#77725: 31.0.50; Add support for types accepted by `cl-typep' to cl-generic?

From: David Ponce <[email-masked]>
Date: Fri, 25 Apr 2025 11:01:11 +0200

On 2025-04-24 21:38, Stefan Monnier wrote:
>> Here is a test case with a recursion:
>>
>> (cl-deftype T1 ()
>>    "Root type.
>> Check if passed object is a subtype of T1. I.e., if T1 is present in
>> object type parents."
>>    `(satisfies ,(lambda (o) (memq 'T1 (gtype-of o)))))
> 
> Hmm... I can see that it could be handy if you don't know how to
> characterize type T1 other than as the "sum" of its subtypes.
> But this seems rather circular.  Have you seen such things out in
> the wild?

I am using a such "abstract" type in one of my library for exactly
this: "characterize type T1 other than as the "sum" of its subtypes."

> More importantly, with your circularity-breaking approach, if `gtype-of`
> (aka `cl-types-of`) happens to look at T1 first, that check will
> immediately return nil, so `cl-types-of` may decide "Oh, I just
> discovered this is not a T1, so I can skip checking all the subtypes of
> T1".  So, the cycle is broken, but the output is wrong.

Look at T1 first should be possible only if T1 is alone, without any
subtype defined.  This is why I call T1 an "abstract" type.  And as such
it is normal that no object will belong to it directly.  For an object to
belong to T1 it must first belong to one of its "concrete" subtype (with a
builtin Emacs type in parents).  And in this case it is guaranteed that
subtypes will appear before T1 in the list of defined types to check.
So, no recursion on T1 when subtypes exist.

Did I miss something?

> 
> The need for multiple object would be for cases like:
> 
>      (cl-deftype car-foo ()
>        "Car-Foo type.
>      Check if passed object has a subtype of FOO as car."
>        `(satisfies ,(lambda (o) (memq 'FOO (gtype-of (car-safe o))))))
> 
> but I guess this can inf-loop only if we follow a cycle in the object
> (e.g. say if o == (car (car (car o)))).

Not sure to understand what you mean by "need for multiple object", I am sorry.

I tries this, and, for me it works as expected:

(cl-deftype car-foo ()
   "Car-Foo type.
Check if passed object has a subtype of FOO as car."
   `(satisfies ,(lambda (o) (memq 'FOO (gtype-of (car-safe o))))))

(cl-deftype FOO ()
   `(or (and symbol (satisfies ,(lambda (o) (eq o 'FOO))))
        car-foo))

(cl-types-of 'FOO)
=> FOO symbol atom t)

(cl-types-of '(FOO))
=> (FOO car-foo cons list sequence t)

(cl-types-of '(((((FOO))))))
=> (FOO car-foo cons list sequence t)

However, in the last case, cl-types-of is entered 95 times!

This makes me think that errors that might occur when calling
`cl-typep' should be handled in `cl-types-of' to prevent an
incorrect definition from impacting method dispatching on types
correctly defined.
WDYT?

> 
>> (cl-deftype T2 ()
>>    "Recursive on checking for T1, never match."
>>    (declare (parents T1))
>>    `(and T1 (satisfies ,(lambda (o) (equal o '(T2))))))
> 
> Is this definition meaningful with the above definition of T1?
> I don't think it's well-founded.

Correct.  It was just a "bad" example.  The correct definition is T3.

>> (defgtype T3 ()
>>    "Not recursive on T1."
>>    (declare (parents T1))
>>    `(satisfies ,(lambda (o) (equal o '(T3)))))
>>
>> ;; T2 will never match, because `cl-types-of' enters in an endless recursion
>> (cl-typep (list 'T2) 'T1)
>> => nil
> 
> This is both right and wrong: we could return t and that would be
> equally valid.
> 
>> (cl-types-of (list 'T2))
>> => (cons list sequence t)
> 
> And here (T2 T1 cons list sequence t) would also be equally valid.

Sorry, you lost me here.  As I understand it, a type whose definition
enter in an endless recursion is not valid, and should never match?

> 
>>> There's also some stylistic problems with the tests:
>>> - We shouldn't test the return value of `cl-deftype`: I don't think it's
>>>     documented anywhere and I don't think it's good practice to pay
>>>     attention to the return value of declarations.
>> You are certainly right.  I just wanted to check that `cl-deftype'
>> didn't fail, but there is no `should-no-error'.  Would it be better to
>> test for the presence of the type just defined in `cl--type-list'?
> 
> I'd just move the `cl-deftype` calls out of the tests and presume that if
> they signal an error we'll see it.

The problem with this approach is that it is difficult to isolate and cleanup
the definitions needed for test-1 from those needed for test-2.  Also the result
will depends on the order of the cl-deftype for test-1 and test-2, because these
definitions will be effective outside of test-1 and test-2.

But maybe it's not so important after all.  I'll try to reformulate the tests this way.

> 
>>> - They use `eval` too much.
>> This is because `cl-deftype' is a macro and the doc string of
>> `ert-deftest' suggests to wrap macros in `(eval (quote ...))' to test
>> them.
> 
> That's only when you specifically need to test the effect of the
> macro-expansion itself, rather than test the result of running the
> macro-expanded code.  It's rarely needed IME.  An example would be to
> detect when a macro-expansion emits a warning.
> 
> Also, by using this `eval+quote` you can't test the macro in the way
> it's usually used (where it's macro-expanded once during compilation and
> then its result is run in another Emacs session) so you may miss bugs
> such as when the macro's expansion performs a side-effect (like add
> something to a list) which the expanded code expects to have happened
> (but this will have happened during compilation, so the element may not
> be in the list any more when the code is finally executed).
> 
> IOW, I disagree with the docstring.  🙁

I understand.

Thanks