#!/usr/bin/env python3
"""Experiment 04 — memory: refcounts, cycles, __slots__, buffers, and what lies.

Every number here is MEASURED. Any claim you make about Python memory in an
interview should come from a script like this, not from a blog post.

    python3 exp04_memory.py
"""

from __future__ import annotations

import gc
import sys
import tracemalloc
import weakref


def section(title: str) -> None:
    print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")


def claim(text: str) -> None:
    print(f"\nCLAIM: {text}")


def measure(build) -> float:
    """Peak MiB allocated while running `build`, with the result kept alive."""
    gc.collect()
    tracemalloc.start()
    try:
        held = build()
        current, _ = tracemalloc.get_traced_memory()
    finally:
        tracemalloc.stop()
    del held
    gc.collect()
    return current / (1024 * 1024)


# ---------------------------------------------------------------------------
section("1. Reference counting is prompt; the cycle collector is not")
# ---------------------------------------------------------------------------

claim("CPython frees an object the moment its refcount hits zero. Cycles need gc.")


class Tracked:
    def __init__(self, name: str) -> None:
        self.name = name
        self.ref = None

    def __del__(self) -> None:
        print(f"    >>> {self.name} finalized")


obj = Tracked("acyclic")
print(f"  getrefcount(obj) -> {sys.getrefcount(obj)}  (one is getrefcount's own arg)")
print("  del obj ...")
del obj

print("\n  Now a cycle:")
left, right = Tracked("cycle-a"), Tracked("cycle-b")
left.ref = right
right.ref = left
print("  del left, right ...   (refcounts are still 1 each — they hold each other)")
del left, right
print(f"  gc.collect() -> collected {gc.collect()} objects")

claim("Before PEP 442 (Python 3.4), objects with __del__ in a cycle were "
      "UNCOLLECTABLE and leaked into gc.garbage. That was fixed.")
print(f"  gc.garbage is {gc.garbage}  <-- empty; finalizers in cycles are handled now")
print("""
  Remaining hazards, and these are the follow-up questions:
    - Finalization ORDER within a cycle is undefined: __del__ may see a
      half-finalized peer.
    - Exceptions inside __del__ are swallowed and printed to stderr.
    - An object can be RESURRECTED by its own __del__.
  Which is why weakref.finalize or a context manager beats __del__ every time.""")


# ---------------------------------------------------------------------------
section("2. weakref.finalize — cleanup without keeping the object alive")
# ---------------------------------------------------------------------------

claim("A weakref does not contribute to the refcount, so the object still dies.")


class Resource:
    def __init__(self, name: str) -> None:
        self.name = name


res = Resource("db-connection")
weakref.finalize(res, lambda n=res.name: print(f"    >>> released {n}"))
ref = weakref.ref(res)
print(f"  weakref alive before del -> {ref() is not None}")
del res
gc.collect()
print(f"  weakref alive after del  -> {ref() is not None}   <-- and cleanup ran")


# ---------------------------------------------------------------------------
section("3. __slots__ — measured, including the subclass trap")
# ---------------------------------------------------------------------------

N = 200_000


class Fat:
    def __init__(self, a, b, c):
        self.a, self.b, self.c = a, b, c


class Slim:
    __slots__ = ("a", "b", "c")

    def __init__(self, a, b, c):
        self.a, self.b, self.c = a, b, c


class SlimSubclassBroken(Slim):
    """Declares no __slots__ of its own — so instances regain a __dict__."""


class SlimSubclassFixed(Slim):
    __slots__ = ()


claim(f"__slots__ replaces the per-instance __dict__ with fixed offsets. "
      f"Measured over {N:,} instances:")

fat = measure(lambda: [Fat(1, 2, 3) for _ in range(N)])
slim = measure(lambda: [Slim(1, 2, 3) for _ in range(N)])
broken = measure(lambda: [SlimSubclassBroken(1, 2, 3) for _ in range(N)])
fixed = measure(lambda: [SlimSubclassFixed(1, 2, 3) for _ in range(N)])

print(f"  plain class                    {fat:7.1f} MiB")
print(f"  __slots__                      {slim:7.1f} MiB   "
      f"({(1 - slim / fat) * 100:.0f}% smaller)")
print(f"  subclass WITHOUT __slots__     {broken:7.1f} MiB   <-- saving lost")
print(f"  subclass WITH __slots__ = ()   {fixed:7.1f} MiB   <-- saving kept")

print("\n  What it breaks:")
s = Slim(1, 2, 3)
try:
    s.d = 4
except AttributeError as exc:
    print(f"    new attribute -> AttributeError: {exc}")
try:
    weakref.ref(s)
except TypeError as exc:
    print(f"    weakref       -> TypeError: {exc}")
print("    (add '__weakref__' to __slots__ if you need weak references)")
print("\n  EVERY class in the hierarchy must declare __slots__ or the saving")
print("  evaporates. That is the trap, and it is why 'we added __slots__' with")
print("  no measurement is not an answer.")


# ---------------------------------------------------------------------------
section("4. sys.getsizeof lies about anything that holds references")
# ---------------------------------------------------------------------------

strings = [f"string-number-{i:08d}" for i in range(50_000)]
shallow = sys.getsizeof(strings)
deep = shallow + sum(sys.getsizeof(s) for s in strings)

claim("getsizeof measures the object's OWN footprint, not what it points to.")
print(f"  sys.getsizeof(list)           {shallow / 1024:9.1f} KiB   "
      f"<-- header + pointer array only")
print(f"  + sum of the strings          {deep / 1024:9.1f} KiB   "
      f"<-- {deep / shallow:.0f}x larger")

real = measure(lambda: [f"string-number-{i:08d}" for i in range(50_000)])
print(f"  tracemalloc, actually         {real * 1024:9.1f} KiB   "
      f"<-- what it really cost")
print("\n  And even tracemalloc does not equal RSS: CPython's pymalloc keeps freed")
print("  memory in arenas rather than returning it to the OS, so 'I freed it and")
print("  RSS didn't drop' is expected behaviour, not a leak.")


# ---------------------------------------------------------------------------
section("5. Generators vs lists — the number behind the folklore")
# ---------------------------------------------------------------------------

M = 2_000_000
claim(f"A list materializes {M:,} items; a generator holds one frame.")

as_list = measure(lambda: [i * i for i in range(M)])
as_gen = measure(lambda: (i * i for i in range(M)))
print(f"  list comprehension   {as_list:8.2f} MiB")
print(f"  generator expression {as_gen:8.4f} MiB   "
      f"<-- {as_list / max(as_gen, 1e-6):,.0f}x smaller")
print("\n  The honest caveat, which is the follow-up question: the generator is")
print("  only cheaper if you never need the data twice. Re-iterating means")
print("  re-computing, and if the source is I/O that trade can lose badly.")


# ---------------------------------------------------------------------------
section("6. memoryview — slicing without copying")
# ---------------------------------------------------------------------------

SIZE = 32 * 1024 * 1024
claim("Slicing bytes COPIES. Slicing a memoryview does not.")

buffer = bytearray(SIZE)
copied = measure(lambda: bytes(buffer)[: SIZE // 2])
viewed = measure(lambda: memoryview(buffer)[: SIZE // 2])
print(f"  bytes(buf)[:16MiB]    {copied:8.2f} MiB allocated")
print(f"  memoryview(buf)[:16MiB] {viewed:6.4f} MiB allocated   <-- zero copy")

view = memoryview(buffer)
view[0] = 65
print(f"  writes through the view mutate the original: buffer[0] = {buffer[0]}")
print("  This is the buffer protocol. It is how you parse a 2 GB frame without")
print("  a 2 GB copy, and it is the right answer to 'how would you avoid the copy'.")


# ---------------------------------------------------------------------------
section("7. Interning — why `is` sometimes lies")
# ---------------------------------------------------------------------------

claim("Small ints (-5..256) are pre-allocated singletons. Larger ints are not, "
      "but constant folding within one code object can still make them identical.")

a, b = 256, 256
print(f"  a = 256; b = 256          a is b -> {a is b}   (cached singleton)")
c, d = 257, 257
print(f"  c = 257; d = 257          c is d -> {c is d}   "
      f"(same code object -> folded to one constant)")
e, f = int('257'), int('257')
print(f"  int('257') is int('257')         -> {e is f}   <-- computed at runtime")
print(f"  and they are still equal:  e == f -> {e == f}")
print("\n  Never use `is` for value comparison. The identity answer depends on")
print("  the compiler's constant folding, which is not part of the language.")


print("\n" + "=" * 70)
print("Interview form of every claim above lives in ../README.md § B4.")
print("=" * 70)
