Repository: leonardt/hwtypes

Issue:
[feature-request] Product types defined the same way should be the same object.
I Ideally would like the following to work.

```
A0 = Product.from_fields("A",{"a":int})
A1 = Product.from_fields("A",{"a":int})
assert A0 is A1

```
I do not think this would change any existing code and would be immensely helpful.

Comments:

Patch:
diff --git a/hwtypes/adt_meta.py b/hwtypes/adt_meta.py
index 144cc72..0a65710 100644
--- a/hwtypes/adt_meta.py
+++ b/hwtypes/adt_meta.py
@@ -1,13 +1,14 @@
 import itertools as it
 import typing as tp
 from abc import ABCMeta, abstractmethod
-from collections import OrderedDict
-
 import weakref
 
 from types import MappingProxyType
 from collections.abc import Mapping, MutableMapping
+from collections import OrderedDict
 from .util import TypedProperty
+from .util import OrderedFrozenDict
+from .util import _issubclass
 
 __all__ = ['BoundMeta', 'TupleMeta', 'ProductMeta', 'SumMeta', 'EnumMeta']
 
@@ -34,6 +35,25 @@ def is_adt_type(t):
 
 class ReservedNameError(Exception): pass
 
+RESERVED_NAMES = frozenset({
+    'enumerate',
+    'fields',
+    'field_dict',
+    'is_bound',
+    'is_cached',
+    'value',
+    'value_dict',
+})
+
+RESERVED_SUNDERS = frozenset({
+    '_cached_',
+    '_fields_',
+    '_field_table_',
+    '_unbound_base_',
+})
+
+RESERVED_ATTRS = frozenset(RESERVED_NAMES | RESERVED_SUNDERS)
+
 # Can't have abstract metaclass https://bugs.python.org/issue36881
 class BoundMeta(type): #, metaclass=ABCMeta):
     # (UnboundType, (types...)) : BoundType
@@ -55,10 +75,9 @@ class BoundMeta(type): #, metaclass=ABCMeta):
         return super().__call__(*args, **kwargs)
 
     def __new__(mcs, name, bases, namespace, fields=None, **kwargs):
-        if '_fields_' in namespace:
-            raise ReservedNameError('class attribute _fields_ is reserved by the type machinery')
-        if '_unbound_base_' in namespace:
-            raise ReservedNameError('class attribute _unbound_base_ is reserved by the type machinery')
+        for rname in RESERVED_SUNDERS:
+            if rname in namespace:
+                raise ReservedNameError(f'class attribute {rname} is reserved by the type machinery')
 
         bound_types = fields
         has_bound_base = False
@@ -82,9 +101,9 @@ class BoundMeta(type): #, metaclass=ABCMeta):
                     if hasattr(t, '_fields_cb'):
                         bound_types = t._fields_cb(bound_types)
 
-        namespace['_fields_'] = bound_types
-        namespace['_unbound_base_'] = None
         t = super().__new__(mcs, name, bases, namespace, **kwargs)
+        t._fields_ = bound_types
+        t._unbound_base_ = None
 
         if bound_types is None:
             # t is a unbound type
@@ -106,13 +125,14 @@ class BoundMeta(type): #, metaclass=ABCMeta):
         return tuple(idx)
 
     def __getitem__(cls, idx) -> 'BoundMeta':
+        mcs = type(cls)
         if not isinstance(idx, tp.Iterable):
             idx = idx,
 
         idx = cls._fields_cb(idx)
 
         try:
-            return BoundMeta._class_cache[cls, idx]
+            return mcs._class_cache[cls, idx]
         except KeyError:
             pass
 
@@ -123,9 +143,10 @@ class BoundMeta(type): #, metaclass=ABCMeta):
         bases.extend(b[idx] for b in cls.__bases__ if isinstance(b, BoundMeta))
         bases = tuple(bases)
         class_name = '{}[{}]'.format(cls.__name__, ', '.join(map(lambda t : t.__name__, idx)))
-        t = type(cls)(class_name, bases, {}, fields=idx)
-        t.__module__ = cls.__module__
-        BoundMeta._class_cache[cls, idx] = t
+
+        t = mcs(class_name, bases, {'__module__' : cls.__module__}, fields=idx)
+        mcs._class_cache[cls, idx] = t
+        t._cached_ = True
         return t
 
     @property
@@ -152,17 +173,21 @@ class BoundMeta(type): #, metaclass=ABCMeta):
     def __repr__(cls):
         return f"{cls.__name__}"
 
-    def rebind(cls, A : type, B : type):
+    def rebind(cls, A: type, B: type, rebind_sub_types: bool = False):
         new_fields = []
         for T in cls.fields:
-            if T == A:
+            if T == A or (rebind_sub_types and _issubclass(T,A)):
                 new_fields.append(B)
             elif isinstance(T, BoundMeta):
-                new_fields.append(T.rebind(A, B))
+                new_fields.append(T.rebind(A, B, rebind_sub_types))
             else:
                 new_fields.append(T)
         return cls.unbound_t[new_fields]
 
+    @property
+    def is_cached(cls):
+        return getattr(cls, '_cached_', False)
+
 class TupleMeta(BoundMeta):
     def __getitem__(cls, idx):
         if cls.is_bound:
@@ -185,9 +210,9 @@ class TupleMeta(BoundMeta):
     def field_dict(cls):
         return MappingProxyType({idx : field for idx, field in enumerate(cls.fields)})
 
-_RESERVED_NAMES = {'enumerate', 'fields', 'field_dict', 'is_bound', 'value', 'value_dict'}
+
 class ProductMeta(TupleMeta):
-    def __new__(mcs, name, bases, namespace, **kwargs):
+    def __new__(mcs, name, bases, namespace, cache=False, **kwargs):
         fields = {}
         ns = {}
         for base in bases:
@@ -197,9 +222,14 @@ class ProductMeta(TupleMeta):
                         raise TypeError(f'Conflicting definitions of field {k}')
                     else:
                         fields[k] = v
+
         for k, v in namespace.items():
-            if _is_sunder(k) or _is_dunder(k) or _is_descriptor(v):
+            if k in RESERVED_SUNDERS:
+                raise ReservedNameError(f'class attribute {k} is reserved by the type machinery')
+            elif _is_dunder(k) or _is_sunder(k) or _is_descriptor(v):
                 ns[k] = v
+            elif k in RESERVED_NAMES:
+                raise ReservedNameError(f'Field name {k} is resevsed by the type machinery')
             elif isinstance(v, type):
                 if k in fields:
                     raise TypeError(f'Conflicting definitions of field {k}')
@@ -209,22 +239,36 @@ class ProductMeta(TupleMeta):
                 ns[k] = v
 
         if fields:
-            return mcs._from_fields(fields, name, bases, ns,  **kwargs)
+            return mcs._from_fields(fields, name, bases, ns, cache, **kwargs)
         else:
             return super().__new__(mcs, name, bases, ns, **kwargs)
 
+    def __init__(cls, name, bases, namespace, cache=False, **kwargs):
+        return super().__init__(name, bases, namespace, **kwargs)
+
     @classmethod
-    def _from_fields(mcs, fields, name, bases, ns, **kwargs):
+    def _from_fields(mcs, fields, name, bases, ns, cache, **kwargs):
+        if cache:
+            ns_idx = set()
+            for k,v in ns.items():
+                if not _is_dunder(k):
+                    ns_idx.add((k,v))
+
+            fields_idx = tuple(map(tuple, fields.items()))
+            cache_idx = (fields_idx,
+                    bases,
+                    name,
+                    frozenset(ns_idx),
+                    frozenset(kwargs.items()),)
+
+            try:
+                return mcs._class_cache[cache_idx]
+            except KeyError:
+                pass
+
         # not strictly necessary could iterative over class dict finding
         # TypedProperty to reconstruct _field_table_ but that seems bad
-        if '_field_table_' in ns:
-            raise ReservedNameError('class attribute _field_table_ is reserved by the type machinery')
-        else:
-            ns['_field_table_'] = OrderedDict()
-
-        for field in fields:
-            if field in _RESERVED_NAMES:
-                raise ReservedNameError(f'Field name {field} is reserved by the type machinery')
+        field_table = dict()
 
         def _get_tuple_base(bases):
             for base in bases:
@@ -257,13 +301,16 @@ class ProductMeta(TupleMeta):
         for field_name, field_type in fields.items():
             assert field_name not in ns
             idx = idx_table[field_name]
-            ns['_field_table_'][field_name] = field_type
+            field_table[field_name] = field_type
             ns[field_name] = _make_prop(field_type, idx)
 
+
         # this is all really gross but I don't know how to do this cleanly
         # need to build t so I can call super() in new and init
         # need to exec to get proper signatures
         t = super().__new__(mcs, name, bases, ns, **kwargs)
+        t._field_table_ = OrderedFrozenDict(field_table)
+        t._cahced_ = cache
         gs = {name : t, 'ProductMeta' : ProductMeta}
         ls = {}
 
@@ -286,16 +333,8 @@ def __init__(self, {type_sig}):
         exec(__init__, gs, ls)
         t.__init__ = ls['__init__']
 
-        product_base = None
-        for base in bases:
-            if isinstance(base, mcs):
-                if product_base is None:
-                    product_base = base
-                else:
-                    raise TypeError('Can only inherit from one product type')
-
-        if product_base is not None and not product_base.is_bound:
-            t._unbound_base_ = product_base
+        if cache:
+            mcs._class_cache[cache_idx] = t
 
         return t
 
@@ -306,51 +345,46 @@ def __init__(self, {type_sig}):
         else:
             raise TypeError("Cannot bind product types with getitem")
 
-    def __repr__(cls):
-        if cls.is_bound:
-            field_spec = ', '.join(map('{0[0]}={0[1].__name__}'.format, cls.field_dict.items()))
-            return f"{cls.__bases__[0].__name__}('{cls.__name__}', {field_spec})"
-        else:
-            return super().__repr__()
-
     @property
     def field_dict(cls):
         return MappingProxyType(cls._field_table_)
 
     def from_fields(cls,
-            class_name: str,
+            name: str,
             fields: tp.Mapping[str, type],
-            module: tp.Optional[str] = None,
-            qualname: tp.Optional[str] = None):
+            cache: tp.Optional[bool] = None):
+
         if cls.is_bound:
             raise TypeError('Type must not be bound')
 
+        for field in fields:
+            if field in RESERVED_ATTRS:
+                raise ReservedNameError(f'Field name {field} is reserved by the type machinery')
+
         ns = {}
 
-        if module is None:
-            module = cls.__module__
+        if cache is None:
+            cache = True
 
-        if qualname is None:
-            qualname = class_name
 
-        ns['__module__'] = module
-        ns['__qualname__'] = qualname
+        ns['__module__'] = cls.__module__
+        ns['__qualname__'] = name
 
-        return cls._from_fields(fields, class_name, (cls,), ns)
+        return cls._from_fields(fields, name, (cls,), ns, cache)
 
 
-    def rebind(cls, A : type, B : type):
+    def rebind(cls, A: type, B: type, rebind_sub_types: bool = False):
         new_fields = OrderedDict()
         for field, T in cls.field_dict.items():
-            if T == A:
+            if T == A or (rebind_sub_types and _issubclass(T,A)):
                 new_fields[field] = B
             elif isinstance(T, BoundMeta):
-                new_fields[field] = T.rebind(A, B)
+                new_fields[field] = T.rebind(A, B, rebind_sub_types)
             else:
                 new_fields[field] = T
 
         if new_fields != cls.field_dict:
-            return cls.unbound_t.from_fields(cls.__name__, new_fields, cls.__module__, cls.__qualname__)
+            return cls.unbound_t.from_fields(cls.__name__, new_fields, cache=cls.is_cached)
         else:
             return cls
 
@@ -369,11 +403,12 @@ class SumMeta(BoundMeta):
 
         if fields is not None:
             for field in fields:
-                if field.__name__ in _RESERVED_NAMES:
-                    raise ReservedNameError(f'Field name {field.__name__} is reserved by the type machinery')
-                elif field.__name__ in namespace:
-                    raise TypeError(f'Field name {field.__name__} cannot be used as a class attribute')
-                namespace[field.__name__] = _make_prop(field)
+                fname = field.__name__
+                if fname in RESERVED_ATTRS:
+                    raise ReservedNameError(f'Field name {fname} is reserved by the type machinery')
+                elif fname in namespace:
+                    raise TypeError(f'Field name {fname} cannot be used as a class attribute')
+                namespace[fname] = _make_prop(field)
 
         return super().__new__(mcs, name, bases, namespace, fields, **kwargs)
 
@@ -398,36 +433,36 @@ class EnumMeta(BoundMeta):
             return 'Auto()'
 
     def __new__(mcs, cls_name, bases, namespace, **kwargs):
-        if '_field_table_' in namespace:
-            raise ReservedNameError('class attribute _field_table_ is reserved by the type machinery')
-
         elems = {}
         ns = {}
 
         for k, v in namespace.items():
-            if _is_dunder(k) or _is_sunder(k) or _is_descriptor(v):
+            if k in RESERVED_SUNDERS:
+                raise ReservedNameError(f'class attribute {k} is reserved by the type machinery')
+            elif _is_dunder(k) or _is_sunder(k) or _is_descriptor(v):
                 ns[k] = v
-            elif k in _RESERVED_NAMES:
+            elif k in RESERVED_NAMES:
                 raise ReservedNameError(f'Field name {k} is resevsed by the type machinery')
             elif isinstance(v,  (int, mcs.Auto)):
                 elems[k] = v
             else:
                 raise TypeError(f'Enum value should be int not {type(v)}')
 
-        ns['_field_table_'] = name_table = dict()
         t = super().__new__(mcs, cls_name, bases, ns, **kwargs)
+        name_table = dict()
 
         if not elems:
             return t
 
         for name, value in elems.items():
             elem = t.__new__(t)
-            elem.__init__(value)
+            t.__init__(elem, value)
             setattr(elem, '_name_', name)
             name_table[name] = elem
             setattr(t, name, elem)
 
         t._fields_ = tuple(name_table.values())
+        t._field_table_ = name_table
 
         enum_base = None
         for base in bases:
@@ -454,7 +489,7 @@ class EnumMeta(BoundMeta):
     def enumerate(cls):
         yield from cls.fields
 
-    def rebind(cls, A : type, B : type):
+    def rebind(cls, A: type, B: type, rebind_sub_types: bool = False):
         # Enums aren't bound to types
         # could potentialy rebind values but that seems annoying
         return cls
diff --git a/hwtypes/util.py b/hwtypes/util.py
index c403ce7..a68bf27 100644
--- a/hwtypes/util.py
+++ b/hwtypes/util.py
@@ -1,5 +1,44 @@
+from collections import OrderedDict
+from collections.abc import Mapping, MutableMapping
 import typing as tp
 
+class FrozenDict(Mapping):
+    __slots__ = '_d', '_hash'
+
+    def __init__(self, *args, **kwargs):
+        self._d = dict(*args, **kwargs)
+        self._hash = hash(frozenset(self.items()))
+
+    def __getitem__(self, key):
+        return self._d.__getitem__(key)
+
+    def __iter__(self):
+        return self._d.__iter__()
+
+    def __len__(self):
+        return self._d.__len__()
+
+    def __eq__(self, other):
+        if isinstance(other, type(self)):
+            return self._d == other._d
+        else:
+            return self._d == other
+
+    def __ne__(self, other):
+        return not (self == other)
+
+    def __hash__(self):
+        return self._hash
+
+
+class OrderedFrozenDict(FrozenDict):
+    __slots__ = ()
+
+    def __init__(self, *args, **kwargs):
+        self._d = OrderedDict(*args, **kwargs)
+        self._hash = hash(tuple(self.items()))
+
+
 class TypedProperty:
     '''
     Behaves mostly like property except:


Test patch:
diff --git a/tests/test_adt.py b/tests/test_adt.py
index 3b8115c..414cb33 100644
--- a/tests/test_adt.py
+++ b/tests/test_adt.py
@@ -1,9 +1,8 @@
 import pytest
 from hwtypes.adt import Product, Sum, Enum, Tuple
-from hwtypes.adt_meta import _RESERVED_NAMES, ReservedNameError
+from hwtypes.adt_meta import RESERVED_ATTRS, ReservedNameError
 from hwtypes.modifiers import new
 from hwtypes.adt_util import rebind_bitvector
-from hwtypes.bit_vector import AbstractBitVector, BitVector
 
 class En1(Enum):
     a = 0
@@ -15,12 +14,7 @@ class En2(Enum):
     d = 1
 
 
-class En3(Enum):
-    e = 3
-    f = 4
-
-
-class Pr(Product):
+class Pr(Product, cache=True):
     x = En1
     y = En2
 
@@ -132,6 +126,7 @@ def test_product():
         p[0] = En2.c
 
     assert Pr != Pr2
+    assert Pr is Product.from_fields('Pr', {'x' : En1, 'y' : En2 }, cache=True)
     assert Pr.field_dict == Pr2.field_dict
     assert Pr.field_dict != Pr3.field_dict
 
@@ -142,15 +137,16 @@ def test_product_from_fields():
     assert issubclass(P, Tuple[int, str])
     assert P.A == int
     assert P.B == str
-    assert P.__name__ == 'P'
     assert P.__module__ == Product.__module__
-    assert P.__qualname__ == 'P'
 
-    P = Product.from_fields('P', {'A' : int, 'B' : str}, module='foo')
-    assert P.__module__ == 'foo'
+    assert P is Product.from_fields('P', {'A' : int, 'B' : str})
+
+    P2 = Product.from_fields('P', {'B' : str, 'A' : int})
+    assert P2 is not P
 
-    P = Product.from_fields('P', {'A' : int, 'B' : str}, qualname='Foo.P')
-    assert P.__qualname__ == 'Foo.P'
+    P3 = Product.from_fields('P', {'A' : int, 'B' : str}, cache=False)
+    assert P3 is not P
+    assert P3 is not P2
 
     with pytest.raises(TypeError):
         Pr.from_fields('P', {'A' : int, 'B' : str})
@@ -229,7 +225,7 @@ def test_repr(T):
 
 
 @pytest.mark.parametrize("T_field", [(Enum, '0'), (Product, 'int')])
-@pytest.mark.parametrize("field_name", list(_RESERVED_NAMES))
+@pytest.mark.parametrize("field_name", list(RESERVED_ATTRS))
 def test_reserved(T_field, field_name):
     T, field = T_field
     l_dict = {'T' : T}
@@ -252,60 +248,3 @@ def test_unbound_t(t, base):
     with pytest.raises(AttributeError):
         sub_t.unbound_t
 
-@pytest.mark.parametrize("T", [Tu, Su, Pr])
-def test_rebind(T):
-    assert En1 in T.fields
-    assert En3 not in T.fields
-    T2 = T.rebind(En1, En3)
-    assert En1 not in T2.fields
-    assert En3 in T2.fields
-
-
-class A: pass
-class B: pass
-class C: pass
-class D: pass
-class P1(Product):
-    A = A
-    B = B
-
-S1 = Sum[C, P1]
-
-class P2(Product):
-    S1 = S1
-    C = C
-
-def test_rebind_recusrive():
-    P3 = P2.rebind(A, D)
-    assert P3.S1.field_dict['P1'].A == D
-    assert P3.S1.field_dict['P1'].B == B
-    assert C in P3.S1.fields
-    P4 = P3.rebind(C, D)
-    assert P4.C == D
-    assert D in P4.S1.fields
-    P5 = P2.rebind(P1, A)
-    assert P5.S1 == Sum[C, A]
-
-
-class F(Product):
-    Y = AbstractBitVector
-
-
-class P(Product):
-    X = AbstractBitVector[16]
-    S = Sum[AbstractBitVector[4], AbstractBitVector[8]]
-    T = Tuple[AbstractBitVector[32]]
-    F = F
-
-
-def test_rebind_bv():
-    P_bound = rebind_bitvector(P, BitVector)
-    assert P_bound.X == BitVector[16]
-    assert P_bound.S == Sum[BitVector[4], BitVector[8]]
-    assert P_bound.T[0] == BitVector[32]
-    assert P_bound.F.Y == BitVector
-
-    P_unbound = rebind_bitvector(P_bound, AbstractBitVector)
-    assert P_unbound.X == AbstractBitVector[16]
-    assert P_unbound.S == Sum[AbstractBitVector[4], AbstractBitVector[8]]
-    assert P_unbound.T[0] == AbstractBitVector[32]
diff --git a/tests/test_rebind.py b/tests/test_rebind.py
new file mode 100644
index 0000000..b01726c
--- /dev/null
+++ b/tests/test_rebind.py
@@ -0,0 +1,149 @@
+import pytest
+
+from hwtypes.adt import Product, Sum, Enum, Tuple
+from hwtypes.adt_util import rebind_bitvector
+from hwtypes.bit_vector import AbstractBitVector, BitVector, AbstractBit, Bit
+from hwtypes.smt_bit_vector import SMTBit
+from hwtypes.util import _issubclass
+
+class A: pass
+class B: pass
+class C(A): pass
+class D(B): pass
+
+class E(Enum):
+    A = 0
+    B = 1
+    C = 2
+    E = 3
+
+T0 = Tuple[A, B, C, E]
+
+class P0(Product, cache=True):
+    A = A
+    B = B
+    C = C
+    E = E
+
+S0 = Sum[A, B, C, E]
+
+class P1(Product, cache=True):
+    P0 = P0
+    S0 = S0
+    T0 = T0
+    D  = D
+
+S1 = Sum[P0, S0, T0, D]
+
+
+
+@pytest.mark.parametrize("type_0", [A, B, C, D, E])
+@pytest.mark.parametrize("type_1", [A, B, C, D, E])
+@pytest.mark.parametrize("rebind_sub_types", [False, True])
+def test_rebind_enum(type_0, type_1, rebind_sub_types):
+    assert E is E.rebind(type_0, type_1, rebind_sub_types)
+
+
+@pytest.mark.parametrize("T", [T0, S0])
+@pytest.mark.parametrize("type_0", [A, B, C, D, E])
+@pytest.mark.parametrize("type_1", [A, B, C, D, E])
+@pytest.mark.parametrize("rebind_sub_types", [False, True])
+def test_rebind_sum_tuple(T, type_0, type_1, rebind_sub_types):
+    fields = T.fields
+    T_ = T.rebind(type_0, type_1, rebind_sub_types)
+
+    if rebind_sub_types:
+        map_fn = lambda s : type_1 if _issubclass(s, type_0) else s
+    else:
+        map_fn = lambda s : type_1 if s == type_0 else s
+
+    new_fields = map(map_fn, fields)
+
+    assert T_ is T.unbound_t[new_fields]
+
+
+@pytest.mark.parametrize("type_0", [A, B, C, D, E])
+@pytest.mark.parametrize("type_1", [A, B, C, D, E])
+@pytest.mark.parametrize("rebind_sub_types", [False, True])
+def test_rebind_product(type_0, type_1, rebind_sub_types):
+    field_dict = P0.field_dict
+    P_ = P0.rebind(type_0, type_1, rebind_sub_types)
+
+    if rebind_sub_types:
+        map_fn = lambda s : type_1 if _issubclass(s, type_0) else s
+    else:
+        map_fn = lambda s : type_1 if s == type_0 else s
+
+    new_fields = {}
+    for k,v in field_dict.items():
+        new_fields[k] = map_fn(v)
+
+    assert P_ is Product.from_fields('P0', new_fields)
+
+
+@pytest.mark.parametrize("rebind_sub_types", [False, True])
+def test_rebind_recursive(rebind_sub_types):
+    S_ = S1.rebind(B, A, rebind_sub_types)
+    if rebind_sub_types:
+        gold = Sum[
+            P0.rebind(B, A, rebind_sub_types),
+            S0.rebind(B, A, rebind_sub_types),
+            T0.rebind(B, A, rebind_sub_types),
+            A
+        ]
+    else:
+        gold = Sum[
+            P0.rebind(B, A, rebind_sub_types),
+            S0.rebind(B, A, rebind_sub_types),
+            T0.rebind(B, A, rebind_sub_types),
+            D
+        ]
+
+    assert S_ is gold
+
+    P_ = P1.rebind(B, A, rebind_sub_types)
+    if rebind_sub_types:
+        gold = Product.from_fields('P1', {
+            'P0' : P0.rebind(B, A, rebind_sub_types),
+            'S0' : S0.rebind(B, A, rebind_sub_types),
+            'T0' : T0.rebind(B, A, rebind_sub_types),
+            'D'  : A
+        })
+    else:
+        gold = Product.from_fields('P1', {
+            'P0' : P0.rebind(B, A, rebind_sub_types),
+            'S0' : S0.rebind(B, A, rebind_sub_types),
+            'T0' : T0.rebind(B, A, rebind_sub_types),
+            'D'  : D
+        })
+
+
+    assert P_ is gold
+
+
+class P(Product):
+    X = AbstractBitVector[16]
+    S = Sum[AbstractBitVector[4], AbstractBitVector[8]]
+    T = Tuple[AbstractBitVector[32]]
+    class F(Product):
+        Y = AbstractBitVector
+
+
+def test_rebind_bv():
+    P_bound = rebind_bitvector(P, BitVector)
+    assert P_bound.X == BitVector[16]
+    assert P_bound.S == Sum[BitVector[4], BitVector[8]]
+    assert P_bound.T[0] == BitVector[32]
+    assert P_bound.F.Y == BitVector
+
+    P_unbound = rebind_bitvector(P_bound, AbstractBitVector)
+    assert P_unbound.X == AbstractBitVector[16]
+    assert P_unbound.S == Sum[AbstractBitVector[4], AbstractBitVector[8]]
+    assert P_unbound.T[0] == AbstractBitVector[32]
+
+def test_issue_74():
+    class A(Product):
+        a = Bit
+
+    A_smt = A.rebind(AbstractBit, SMTBit, True)
+    assert A_smt.a is SMTBit