Repository: aio-libs/yarl

Issue:
Replace _val with separate props for url parts
The target is `URL.build` optimization.
The method is crucial for aiohttp web server, avoiding `SplitResult` construction can make the method faster.

`SplitResult` could be restored in every method that need the object.

N.B. Serialization format should support URL classes generated by older library version or the format should not be changed at all (the later looks easier).

Discussion:
codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/aio-libs/yarl/branches/elim_val)

### Merging #1397 will **degrade performances by 13.18%**

<sub>Comparing <code>elim_val</code> (6a44b9f) with <code>master</code> (24f814d)</sub>



### Summary

`⚡ 13` improvements
`❌ 1` regressions
`✅ 68` untouched benchmarks



> :warning: _Please fix the performance issues or [acknowledge them on CodSpeed](https://codspeed.io/aio-libs/yarl/branches/elim_val)._

### Benchmarks breakdown

|     | Benchmark | `master` | `elim_val` | Change |
| --- | --------- | ----------------------- | ------------------- | ------ |
| ❌ | `test_url_build_access_raw_path` | 666.5 µs | 767.6 µs | -13.18% |
| ⚡ | `test_url_build_encoded_with_host_and_port` | 500.6 µs | 454.9 µs | +10.06% |
| ⚡ | `test_url_build_no_netloc` | 448.4 µs | 405.3 µs | +10.62% |
| ⚡ | `test_url_build_no_netloc_relative` | 447.3 µs | 402 µs | +11.28% |
| ⚡ | `test_url_build_with_different_hosts` | 1.3 ms | 1.1 ms | +10.46% |
| ⚡ | `test_url_build_with_host` | 504.1 µs | 455.2 µs | +10.74% |
| ⚡ | `test_url_build_with_host_path_and_port` | 636.6 µs | 581.6 µs | +9.47% |
| ⚡ | `test_url_equality` | 780.1 µs | 507.6 µs | +53.69% |
| ⚡ | `test_url_hash` | 323.3 µs | 220.9 µs | +46.32% |
| ⚡ | `test_url_make_with_many_hosts` | 3.6 ms | 3.3 ms | +8.72% |
| ⚡ | `test_url_make_with_many_ipv4_hosts` | 3.6 ms | 3.3 ms | +9.12% |
| ⚡ | `test_url_to_string` | 308 µs | 261.9 µs | +17.64% |
| ⚡ | `test_url_with_fragment` | 432.3 µs | 392.9 µs | +10.02% |
| ⚡ | `test_url_with_query_to_string` | 356.8 µs | 322.1 µs | +10.77% |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aio-libs/yarl/pull/1397?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) Report
Attention: Patch coverage is `86.14458%` with `23 lines` in your changes missing coverage. Please review.
> Project coverage is 95.63%. Comparing base [(`24f814d`)](https://app.codecov.io/gh/aio-libs/yarl/commit/24f814de5ff6e2cd1aea0bcf8e09bd49bdce844b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) to head [(`6a44b9f`)](https://app.codecov.io/gh/aio-libs/yarl/commit/6a44b9fadcd491e48c3e9692910aac39a4ffd039?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).

| [Files with missing lines](https://app.codecov.io/gh/aio-libs/yarl/pull/1397?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Patch % | Lines |
|---|---|---|
| [yarl/\_url.py](https://app.codecov.io/gh/aio-libs/yarl/pull/1397?src=pr&el=tree&filepath=yarl%2F_url.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#diff-eWFybC9fdXJsLnB5) | 80.34% | [23 Missing :warning: ](https://app.codecov.io/gh/aio-libs/yarl/pull/1397?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1397      +/-   ##
==========================================
- Coverage   96.00%   95.63%   -0.37%     
==========================================
  Files          31       31              
  Lines        5683     5730      +47     
  Branches      353      352       -1     
==========================================
+ Hits         5456     5480      +24     
- Misses        201      224      +23     
  Partials       26       26              
```

| [Flag](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Coverage Δ | |
|---|---|---|
| [CI-GHA](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `95.63% <86.14%> (-0.37%)` | :arrow_down: |
| [MyPy](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `48.53% <44.09%> (-0.23%)` | :arrow_down: |
| [OS-Linux](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.48% <97.63%> (-0.07%)` | :arrow_down: |
| [OS-Windows](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.54% <97.63%> (-0.07%)` | :arrow_down: |
| [OS-macOS](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.22% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.10.11](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.20% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.10.15](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.44% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.11.10](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.44% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.11.9](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.20% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.12.7](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.44% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.13.0](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.44% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.9.13](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.16% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-3.9.20](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.40% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-pypy7.3.16](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.45% <97.63%> (-0.07%)` | :arrow_down: |
| [Py-pypy7.3.17](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.47% <97.63%> (-0.07%)` | :arrow_down: |
| [VM-macos-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.22% <97.63%> (-0.07%)` | :arrow_down: |
| [VM-ubuntu-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.48% <97.63%> (-0.07%)` | :arrow_down: |
| [VM-windows-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.54% <97.63%> (-0.07%)` | :arrow_down: |
| [pytest](https://app.codecov.io/gh/aio-libs/yarl/pull/1397/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.48% <97.63%> (-0.07%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aio-libs/yarl/pull/1397?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).

bdraco: This seems lower and less readable 
bdraco: Going to try one more thing before I throw in the towel on this one
bdraco: still 8% slower making the URL
https://codspeed.io/aio-libs/yarl/branches/elim_val

If we move the URL inside the `pre_encoded_url` and `encode_url`
bdraco: that works but need to make sure we do not cache on default value since unpickling mutates the object and everything else expected `URL` is immutable.

Patch:
diff --git a/CHANGES/1397.misc.rst b/CHANGES/1397.misc.rst
new file mode 120000
index 0000000..b90e5c5
--- /dev/null
+++ b/CHANGES/1397.misc.rst
@@ -0,0 +1,1 @@
+1396.misc.rst
\ No newline at end of file
diff --git a/yarl/_parse.py b/yarl/_parse.py
index 0de4a6d..f8aa927 100644
--- a/yarl/_parse.py
+++ b/yarl/_parse.py
@@ -3,7 +3,7 @@
 import re
 import unicodedata
 from functools import lru_cache
-from typing import Final, Union
+from typing import Union
 from urllib.parse import scheme_chars, uses_netloc
 
 from ._quoters import QUOTER
@@ -20,11 +20,6 @@ UNSAFE_URL_BYTES_TO_REMOVE = ["\t", "\r", "\n"]
 USES_AUTHORITY = frozenset(uses_netloc)
 
 SplitURL = tuple[str, str, str, str, str]
-SCHEME: Final[int] = 0
-NETLOC: Final[int] = 1
-PATH: Final[int] = 2
-QUERY: Final[int] = 3
-FRAGMENT: Final[int] = 4
 
 
 def split_url(url: str) -> SplitURL:
diff --git a/yarl/_url.py b/yarl/_url.py
index ba3452b..b4f637c 100644
--- a/yarl/_url.py
+++ b/yarl/_url.py
@@ -2,6 +2,7 @@ import re
 import sys
 import warnings
 from collections.abc import Mapping, Sequence
+from enum import Enum
 from functools import _CacheInfo, lru_cache
 from ipaddress import ip_address
 from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, Union, overload
@@ -12,10 +13,6 @@ from multidict import MultiDict, MultiDictProxy
 from propcache.api import under_cached_property as cached_property
 
 from ._parse import (
-    FRAGMENT,
-    NETLOC,
-    QUERY,
-    SCHEME,
     USES_AUTHORITY,
     SplitURL,
     make_netloc,
@@ -80,6 +77,15 @@ else:
     Self = Any
 
 
+class UndefinedType(Enum):
+    """Singleton type for use with not set sentinel values."""
+
+    _singleton = 0
+
+
+UNDEFINED = UndefinedType._singleton
+
+
 class CacheInfo(TypedDict):
     """Host encoding cache."""
 
@@ -134,7 +140,7 @@ def rewrite_module(obj: _T) -> _T:
 
 
 @lru_cache
-def encode_url(url_str: str) -> tuple[SplitURL, _InternalURLCache]:
+def encode_url(url_str: str) -> "URL":
     """Parse unencoded URL."""
     cache: _InternalURLCache = {}
     host: Union[str, None]
@@ -184,17 +190,29 @@ def encode_url(url_str: str) -> tuple[SplitURL, _InternalURLCache]:
     cache["scheme"] = scheme
     cache["raw_query_string"] = query
     cache["raw_fragment"] = fragment
-    return (scheme, netloc, path, query, fragment), cache
+    self = object.__new__(URL)
+    self._cache = cache
+    self._scheme = scheme
+    self._netloc = netloc
+    self._path = path
+    self._query = query
+    self._fragment = fragment
+    return self
 
 
 @lru_cache
-def pre_encoded_url(url_str: str) -> tuple[SplitURL, _InternalURLCache]:
+def pre_encoded_url(url_str: str) -> "URL":
     """Parse pre-encoded URL."""
-    return split_url(url_str), {}
+    self = object.__new__(URL)
+    val = split_url(url_str)
+    self._scheme, self._netloc, self._path, self._query, self._fragment = val
+    self._cache = {}
+    return self
 
 
 @rewrite_module
 class URL:
+
     # Don't derive from str
     # follow pathlib.Path design
     # probably URL will not suffer from pathlib problems:
@@ -264,17 +282,21 @@ class URL:
     #               / path-noscheme
     #               / path-empty
     # absolute-URI  = scheme ":" hier-part [ "?" query ]
-    __slots__ = ("_cache", "_val")
+    __slots__ = ("_cache", "_scheme", "_netloc", "_path", "_query", "_fragment")
 
-    _val: SplitURL
+    _scheme: str
+    _netloc: str
+    _path: str
+    _query: str
+    _fragment: str
 
     def __new__(
         cls,
-        val: Union[str, SplitResult, "URL"] = "",
+        val: Union[str, SplitResult, "URL", UndefinedType] = UNDEFINED,
         *,
         encoded: bool = False,
         strict: Union[bool, None] = None,
-    ) -> Self:
+    ) -> "URL":
         if strict is not None:  # pragma: no cover
             warnings.warn("strict parameter is ignored")
         if type(val) is str:
@@ -284,22 +306,31 @@ class URL:
         elif type(val) is SplitResult:
             if not encoded:
                 raise ValueError("Cannot apply decoding to SplitResult")
-            self = object.__new__(cls)
-            self._val = val
+            self = object.__new__(URL)
+            self._scheme, self._netloc, self._path, self._query, self._fragment = val
             self._cache = {}
             return self
         elif isinstance(val, str):
             val = str(val)
+        elif val is UNDEFINED:
+            # Special case for UNDEFINED
+            # since it might be unpickling
+            # and we do not want to cache
+            # as the `__set_state__` call would
+            # mutate the URL object in the
+            # `pre_encoded_url` or `encoded_url`
+            # caches
+            self = object.__new__(URL)
+            self._scheme = ""
+            self._netloc = ""
+            self._path = ""
+            self._query = ""
+            self._fragment = ""
+            self._cache = {}
+            return self
         else:
             raise TypeError("Constructor parameter should be str")
-        if encoded:
-            split_result, cache = pre_encoded_url(val)
-        else:
-            split_result, cache = encode_url(val)
-        self = object.__new__(cls)
-        self._val = split_result
-        self._cache = cache
-        return self
+        return pre_encoded_url(val) if encoded else encode_url(val)
 
     @classmethod
     def build(
@@ -390,10 +421,18 @@ class URL:
             query_string = get_str_query(query) or ""
 
         url = object.__new__(cls)
-        url._val = (scheme, netloc, path, query_string, fragment)
+        url._scheme = scheme
+        url._netloc = netloc
+        url._path = path
+        url._query = query_string
+        url._fragment = fragment
         url._cache = {}
         return url
 
+    @cached_property
+    def _val(self) -> SplitURL:
+        return (self._scheme, self._netloc, self._path, self._query, self._fragment)
+
     @classmethod
     def _from_tup(cls, val: SplitURL) -> "URL":
         """Create a new URL from a tuple.
@@ -403,7 +442,7 @@ class URL:
         (scheme, netloc, path, query, fragment)
         """
         self = object.__new__(cls)
-        self._val = val
+        self._scheme, self._netloc, self._path, self._query, self._fragment = val
         self._cache = {}
         return self
 
@@ -411,16 +450,18 @@ class URL:
         raise TypeError(f"Inheriting a class {cls!r} from URL is forbidden")
 
     def __str__(self) -> str:
-        val = self._val
-        scheme, netloc, path, query, fragment = val
-        if not path and netloc and (query or fragment):
+        if not self._path and self._netloc and (self._query or self._fragment):
             path = "/"
+        else:
+            path = self._path
         if (port := self.explicit_port) is not None and port == self._default_port:
             # port normalization - using None for default ports to remove from rendering
             # https://datatracker.ietf.org/doc/html/rfc3986.html#section-6.2.3
             host = self.host_subcomponent
             netloc = make_netloc(self.raw_user, self.raw_password, host, None)
-        return unsplit_result(scheme, netloc, path, query, fragment)
+        else:
+            netloc = self._netloc
+        return unsplit_result(self._scheme, netloc, path, self._query, self._fragment)
 
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}('{str(self)}')"
@@ -432,25 +473,22 @@ class URL:
         if type(other) is not URL:
             return NotImplemented
 
-        val1 = self._val
-        scheme1, netloc1, path1, query1, fragment1 = val1
-        if not path1 and netloc1:
-            val1 = (scheme1, netloc1, "/", query1, fragment1)
-
-        val2 = other._val
-        scheme2, netloc2, path2, query2, fragment2 = val2
-        if not path2 and netloc2:
-            val2 = (scheme2, netloc2, "/", query2, fragment2)
-
-        return val1 == val2
+        path1 = "/" if not self._path and self._netloc else self._path
+        path2 = "/" if not other._path and other._netloc else other._path
+        return (
+            self._scheme == other._scheme
+            and self._netloc == other._netloc
+            and path1 == path2
+            and self._query == other._query
+            and self._fragment == other._fragment
+        )
 
     def __hash__(self) -> int:
         if (ret := self._cache.get("hash")) is None:
-            val = self._val
-            scheme, netloc, path, query, fragment = val
-            if not path and netloc:
-                val = (scheme, netloc, "/", query, fragment)
-            ret = self._cache["hash"] = hash(val)
+            path = "/" if not self._path and self._netloc else self._path
+            ret = self._cache["hash"] = hash(
+                (self._scheme, self._netloc, path, self._query, self._fragment)
+            )
         return ret
 
     def __le__(self, other: object) -> bool:
@@ -482,8 +520,7 @@ class URL:
         return self.update_query(query)
 
     def __bool__(self) -> bool:
-        _, netloc, path, query, fragment = self._val
-        return bool(netloc or path or query or fragment)
+        return bool(self._netloc or self._path or self._query or self._fragment)
 
     def __getstate__(self) -> tuple[SplitResult]:
         return (tuple.__new__(SplitResult, self._val),)
@@ -491,15 +528,16 @@ class URL:
     def __setstate__(self, state):
         if state[0] is None and isinstance(state[1], dict):
             # default style pickle
-            self._val = tuple.__new__(SplitResult, state[1]["_val"])
+            val = state[1]["_val"]
         else:
-            self._val, *unused = state
+            val, *unused = state
+        self._scheme, self._netloc, self._path, self._query, self._fragment = val
         self._cache = {}
 
     def _cache_netloc(self) -> None:
         """Cache the netloc parts of the URL."""
         c = self._cache
-        split_loc = split_netloc(self._val[NETLOC])
+        split_loc = split_netloc(self._netloc)
         c["raw_user"], c["raw_password"], c["raw_host"], c["explicit_port"] = split_loc
 
     def is_absolute(self) -> bool:
@@ -527,7 +565,7 @@ class URL:
             # If the explicit port is None, then the URL must be
             # using the default port unless its a relative URL
             # which does not have an implicit port / default port
-            return self._val[NETLOC] != ""
+            return self._netloc != ""
         return explicit == self._default_port
 
     def origin(self) -> "URL":
@@ -545,15 +583,14 @@ class URL:
 
         user, password, path, query and fragment are removed.
         """
-        scheme, netloc, path, query, fragment = self._val
-        if not netloc:
+        if not (netloc := self._netloc):
             raise ValueError("URL should be absolute")
-        if not scheme:
+        if not (scheme := self._scheme):
             raise ValueError("URL should have scheme")
         if "@" in netloc:
             encoded_host = self.host_subcomponent
             netloc = make_netloc(None, None, encoded_host, self.explicit_port)
-        elif not path and not query and not fragment:
+        elif not self._path and not self._query and not self._fragment:
             return self
         return self._from_tup((scheme, netloc, "", "", ""))
 
@@ -563,10 +600,9 @@ class URL:
         scheme, user, password, host and port are removed.
 
         """
-        _, netloc, path, query, fragment = self._val
-        if not netloc:
+        if not self._netloc:
             raise ValueError("URL should be absolute")
-        return self._from_tup(("", "", path, query, fragment))
+        return self._from_tup(("", "", self._path, self._query, self._fragment))
 
     @cached_property
     def absolute(self) -> bool:
@@ -580,7 +616,7 @@ class URL:
         # Checking `netloc` is faster than checking `hostname`
         # because `hostname` is a property that does some extra work
         # to parse the host from the `netloc`
-        return self._val[NETLOC] != ""
+        return self._netloc != ""
 
     @cached_property
     def scheme(self) -> str:
@@ -589,7 +625,7 @@ class URL:
         Empty string for relative URLs or URLs starting with //
 
         """
-        return self._val[SCHEME]
+        return self._scheme
 
     @cached_property
     def raw_authority(self) -> str:
@@ -598,12 +634,12 @@ class URL:
         Empty string for relative URLs.
 
         """
-        return self._val[NETLOC]
+        return self._netloc
 
     @cached_property
     def _default_port(self) -> Union[int, None]:
         """Default port for the scheme or None if not known."""
-        return DEFAULT_PORTS.get(self._val[SCHEME])
+        return DEFAULT_PORTS.get(self._scheme)
 
     @cached_property
     def authority(self) -> str:
@@ -772,8 +808,7 @@ class URL:
         / for absolute URLs without path part.
 
         """
-        _, netloc, path, _, _ = self._val
-        return "/" if not path and netloc else path
+        return "/" if not self._path and self._netloc else self._path
 
     @cached_property
     def path(self) -> str:
@@ -798,7 +833,7 @@ class URL:
     @cached_property
     def _parsed_query(self) -> list[tuple[str, str]]:
         """Parse query part of URL."""
-        return parse_qsl(self._val[QUERY], keep_blank_values=True)
+        return parse_qsl(self._query, keep_blank_values=True)
 
     @cached_property
     def query(self) -> "MultiDictProxy[str]":
@@ -817,7 +852,7 @@ class URL:
         Empty string if query is missing.
 
         """
-        return self._val[QUERY]
+        return self._query
 
     @cached_property
     def query_string(self) -> str:
@@ -826,7 +861,7 @@ class URL:
         Empty string if query is missing.
 
         """
-        return QS_UNQUOTER(self._val[QUERY])
+        return QS_UNQUOTER(self._query)
 
     @cached_property
     def path_qs(self) -> str:
@@ -836,7 +871,7 @@ class URL:
     @cached_property
     def raw_path_qs(self) -> str:
         """Encoded path of URL with query."""
-        query = self._val[QUERY]
+        query = self._query
         return self.raw_path if not query else f"{self.raw_path}?{query}"
 
     @cached_property
@@ -846,7 +881,7 @@ class URL:
         Empty string if fragment is missing.
 
         """
-        return self._val[FRAGMENT]
+        return self._fragment
 
     @cached_property
     def fragment(self) -> str:
@@ -855,7 +890,7 @@ class URL:
         Empty string if fragment is missing.
 
         """
-        return UNQUOTER(self._val[FRAGMENT])
+        return UNQUOTER(self._fragment)
 
     @cached_property
     def raw_parts(self) -> tuple[str, ...]:
@@ -864,8 +899,8 @@ class URL:
         ('/',) for absolute URLs if *path* is missing.
 
         """
-        _, netloc, path, _, _ = self._val
-        if netloc:
+        path = self._path
+        if self._netloc:
             return ("/", *path[1:].split("/")) if path else ("/",)
         if path and path[0] == "/":
             return ("/", *path[1:].split("/"))
@@ -886,19 +921,21 @@ class URL:
         fragment.
 
         """
-        scheme, netloc, path, query, fragment = self._val
+        path = self._path
         if not path or path == "/":
-            if fragment or query:
-                return self._from_tup((scheme, netloc, path, "", ""))
+            if self._fragment or self._query:
+                return self._from_tup((self._scheme, self._netloc, path, "", ""))
             return self
         parts = path.split("/")
-        return self._from_tup((scheme, netloc, "/".join(parts[:-1]), "", ""))
+        return self._from_tup(
+            (self._scheme, self._netloc, "/".join(parts[:-1]), "", "")
+        )
 
     @cached_property
     def raw_name(self) -> str:
         """The last part of raw_parts."""
         parts = self.raw_parts
-        if not self._val[NETLOC]:
+        if not self._netloc:
             return parts[-1]
         parts = parts[1:]
         return parts[-1] if parts else ""
@@ -932,7 +969,8 @@ class URL:
 
     def _make_child(self, paths: "Sequence[str]", encoded: bool = False) -> "URL":
         """
-        add paths to self._val.path, accounting for absolute vs relative paths,
+        add paths to self._path, accounting
+        for absolute vs relative paths,
         keep existing, but do not create new, empty segments
         """
         parsed: list[str] = []
@@ -957,14 +995,14 @@ class URL:
             parsed += segments[segment_slice_start:]
         parsed.reverse()
 
-        scheme, netloc, path, _, _ = self._val
+        path = self._path
         if path and (old_path_segments := path.split("/")):
             # If the old path ends with a slash, the last segment is an empty string
             # and should be removed before adding the new path segments.
             old_path_cutoff = -1 if old_path_segments[-1] == "" else None
             parsed = [*old_path_segments[:old_path_cutoff], *parsed]
 
-        if netloc := netloc:
+        if netloc := self._netloc:
             # If the netloc is present, we need to ensure that the path is normalized
             parsed = normalize_path_segments(parsed) if needs_normalize else parsed
             if parsed and parsed[0] != "":
@@ -974,7 +1012,7 @@ class URL:
 
         new_path = "/".join(parsed)
 
-        return self._from_tup((scheme, netloc, new_path, "", ""))
+        return self._from_tup((self._scheme, netloc, new_path, "", ""))
 
     def with_scheme(self, scheme: str) -> "URL":
         """Return a new URL with scheme replaced."""
@@ -982,14 +1020,16 @@ class URL:
         if not isinstance(scheme, str):
             raise TypeError("Invalid scheme type")
         lower_scheme = scheme.lower()
-        _, netloc, path, query, fragment = self._val
+        netloc = self._netloc
         if not netloc and lower_scheme in SCHEME_REQUIRES_HOST:
             msg = (
                 "scheme replacement is not allowed for "
                 f"relative URLs for the {lower_scheme} scheme"
             )
             raise ValueError(msg)
-        return self._from_tup((lower_scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (lower_scheme, netloc, self._path, self._query, self._fragment)
+        )
 
     def with_user(self, user: Union[str, None]) -> "URL":
         """Return a new URL with user replaced.
@@ -1000,7 +1040,6 @@ class URL:
 
         """
         # N.B. doesn't cleanup query/fragment
-        scheme, netloc, path, query, fragment = self._val
         if user is None:
             password = None
         elif isinstance(user, str):
@@ -1008,11 +1047,13 @@ class URL:
             password = self.raw_password
         else:
             raise TypeError("Invalid user type")
-        if not netloc:
+        if not (netloc := self._netloc):
             raise ValueError("user replacement is not allowed for relative URLs")
         encoded_host = self.host_subcomponent or ""
         netloc = make_netloc(user, password, encoded_host, self.explicit_port)
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, netloc, self._path, self._query, self._fragment)
+        )
 
     def with_password(self, password: Union[str, None]) -> "URL":
         """Return a new URL with password replaced.
@@ -1029,13 +1070,14 @@ class URL:
             password = QUOTER(password)
         else:
             raise TypeError("Invalid password type")
-        scheme, netloc, path, query, fragment = self._val
-        if not netloc:
+        if not (netloc := self._netloc):
             raise ValueError("password replacement is not allowed for relative URLs")
         encoded_host = self.host_subcomponent or ""
         port = self.explicit_port
         netloc = make_netloc(self.raw_user, password, encoded_host, port)
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, netloc, self._path, self._query, self._fragment)
+        )
 
     def with_host(self, host: str) -> "URL":
         """Return a new URL with host replaced.
@@ -1049,15 +1091,16 @@ class URL:
         # N.B. doesn't cleanup query/fragment
         if not isinstance(host, str):
             raise TypeError("Invalid host type")
-        scheme, netloc, path, query, fragment = self._val
-        if not netloc:
+        if not (netloc := self._netloc):
             raise ValueError("host replacement is not allowed for relative URLs")
         if not host:
             raise ValueError("host removing is not allowed")
         encoded_host = _encode_host(host, validate_host=True) if host else ""
         port = self.explicit_port
         netloc = make_netloc(self.raw_user, self.raw_password, encoded_host, port)
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, netloc, self._path, self._query, self._fragment)
+        )
 
     def with_port(self, port: Union[int, None]) -> "URL":
         """Return a new URL with port replaced.
@@ -1071,23 +1114,24 @@ class URL:
                 raise TypeError(f"port should be int or None, got {type(port)}")
             if not (0 <= port <= 65535):
                 raise ValueError(f"port must be between 0 and 65535, got {port}")
-        scheme, netloc, path, query, fragment = self._val
-        if not netloc:
+        if not (netloc := self._netloc):
             raise ValueError("port replacement is not allowed for relative URLs")
         encoded_host = self.host_subcomponent or ""
         netloc = make_netloc(self.raw_user, self.raw_password, encoded_host, port)
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, netloc, self._path, self._query, self._fragment)
+        )
 
     def with_path(self, path: str, *, encoded: bool = False) -> "URL":
         """Return a new URL with path replaced."""
-        scheme, netloc, _, _, _ = self._val
+        netloc = self._netloc
         if not encoded:
             path = PATH_QUOTER(path)
             if netloc:
                 path = normalize_path(path) if "." in path else path
         if path and path[0] != "/":
             path = f"/{path}"
-        return self._from_tup((scheme, netloc, path, "", ""))
+        return self._from_tup((self._scheme, netloc, path, "", ""))
 
     @overload
     def with_query(self, query: Query) -> "URL": ...
@@ -1110,8 +1154,9 @@ class URL:
         """
         # N.B. doesn't cleanup query/fragment
         query = get_str_query(*args, **kwargs) or ""
-        scheme, netloc, path, _, fragment = self._val
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, self._netloc, self._path, query, self._fragment)
+        )
 
     @overload
     def extend_query(self, query: Query) -> "URL": ...
@@ -1131,14 +1176,15 @@ class URL:
         """
         if not (new_query := get_str_query(*args, **kwargs)):
             return self
-        scheme, netloc, path, query, fragment = self._val
-        if query:
+        if query := self._query:
             # both strings are already encoded so we can use a simple
             # string join
             query += new_query if query[-1] == "&" else f"&{new_query}"
         else:
             query = new_query
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, self._netloc, self._path, query, self._fragment)
+        )
 
     @overload
     def update_query(self, query: Query) -> "URL": ...
@@ -1167,11 +1213,10 @@ class URL:
         else:
             raise ValueError("Either kwargs or single query parameter must be present")
 
-        scheme, netloc, path, query, fragment = self._val
         if in_query is None:
             query = ""
         elif not in_query:
-            pass
+            query = self._query
         elif isinstance(in_query, Mapping):
             qm: MultiDict[QueryVariable] = MultiDict(self._parsed_query)
             qm.update(in_query)
@@ -1196,7 +1241,9 @@ class URL:
                 "Invalid query type: only str, mapping or "
                 "sequence of (key, value) pairs is allowed"
             )
-        return self._from_tup((scheme, netloc, path, query, fragment))
+        return self._from_tup(
+            (self._scheme, self._netloc, self._path, query, self._fragment)
+        )
 
     def without_query_params(self, *query_params: str) -> "URL":
         """Remove some keys from query part and return new URL."""
@@ -1226,10 +1273,11 @@ class URL:
             raise TypeError("Invalid fragment type")
         else:
             raw_fragment = FRAGMENT_QUOTER(fragment)
-        scheme, netloc, path, query, fragment = self._val
-        if fragment == raw_fragment:
+        if self._fragment == raw_fragment:
             return self
-        return self._from_tup((scheme, netloc, path, query, raw_fragment))
+        return self._from_tup(
+            (self._scheme, self._netloc, self._path, self._query, raw_fragment)
+        )
 
     def with_name(self, name: str) -> "URL":
         """Return a new URL with name (last part of path) replaced.
@@ -1248,7 +1296,7 @@ class URL:
         if name in (".", ".."):
             raise ValueError(". and .. values are forbidden")
         parts = list(self.raw_parts)
-        scheme, netloc, _, _, _ = self._val
+        scheme, netloc = self._scheme, self._netloc
         if netloc:
             if len(parts) == 1:
                 parts.append(name)
@@ -1293,9 +1341,16 @@ class URL:
         """
         if type(url) is not URL:
             raise TypeError("url should be URL")
-        orig_scheme, orig_netloc, orig_path, orig_query, orig_fragment = self._val
-        join_scheme, join_netloc, join_path, join_query, join_fragment = url._val
-        scheme = join_scheme or orig_scheme
+
+        orig_scheme = self._scheme
+        orig_path = self._path
+        orig_query = self._query
+        orig_fragment = self._fragment
+        join_netloc = url._netloc
+        join_path = url._path
+        join_query = url._query
+        join_fragment = url._fragment
+        scheme = url._scheme or orig_scheme
 
         if scheme != orig_scheme or scheme not in USES_RELATIVE:
             return url
@@ -1329,7 +1384,14 @@ class URL:
                     path = path[1:]
             path = normalize_path(path) if "." in path else path
 
-        return self._from_tup((scheme, orig_netloc, path, query, fragment))
+        url = object.__new__(URL)
+        url._scheme = scheme
+        url._netloc = self._netloc
+        url._path = path
+        url._query = query
+        url._fragment = fragment
+        url._cache = {}
+        return url
 
     def joinpath(self, *other: str, encoded: bool = False) -> "URL":
         """Return a new URL with the elements in other appended to the path."""
@@ -1352,7 +1414,7 @@ class URL:
         if TYPE_CHECKING:
             assert fragment is not None
         netloc = make_netloc(user, password, host, self.explicit_port)
-        scheme = self._val[SCHEME]
+        scheme = self._scheme
         return unsplit_result(scheme, netloc, path, query_string, fragment)
 
 
@@ -1453,23 +1515,20 @@ def cache_info() -> CacheInfo:
     }
 
 
-_SENTINEL = object()
-
-
 @rewrite_module
 def cache_configure(
     *,
     idna_encode_size: Union[int, None] = _DEFAULT_IDNA_SIZE,
     idna_decode_size: Union[int, None] = _DEFAULT_IDNA_SIZE,
-    ip_address_size: Union[int, None, object] = _SENTINEL,
-    host_validate_size: Union[int, None, object] = _SENTINEL,
-    encode_host_size: Union[int, None, object] = _SENTINEL,
+    ip_address_size: Union[int, None, UndefinedType] = UNDEFINED,
+    host_validate_size: Union[int, None, UndefinedType] = UNDEFINED,
+    encode_host_size: Union[int, None, UndefinedType] = UNDEFINED,
 ) -> None:
     """Configure LRU cache sizes."""
     global _idna_decode, _idna_encode, _encode_host
     # ip_address_size, host_validate_size are no longer
     # used, but are kept for backwards compatibility.
-    if ip_address_size is not _SENTINEL or host_validate_size is not _SENTINEL:
+    if ip_address_size is not UNDEFINED or host_validate_size is not UNDEFINED:
         warnings.warn(
             "cache_configure() no longer accepts the "
             "ip_address_size or host_validate_size arguments, "
@@ -1483,15 +1542,15 @@ def cache_configure(
         for size in (ip_address_size, host_validate_size):
             if size is None:
                 encode_host_size = None
-            elif encode_host_size is _SENTINEL:
-                if size is not _SENTINEL:
+            elif encode_host_size is UNDEFINED:
+                if size is not UNDEFINED:
                     encode_host_size = size
-            elif size is not _SENTINEL:
+            elif size is not UNDEFINED:
                 if TYPE_CHECKING:
                     assert isinstance(size, int)
                     assert isinstance(encode_host_size, int)
                 encode_host_size = max(size, encode_host_size)
-        if encode_host_size is _SENTINEL:
+        if encode_host_size is UNDEFINED:
             encode_host_size = _DEFAULT_ENCODE_SIZE
 
     if TYPE_CHECKING:


Test patch:
diff --git a/tests/test_pickle.py b/tests/test_pickle.py
index 91d4966..4e62820 100644
--- a/tests/test_pickle.py
+++ b/tests/test_pickle.py
@@ -6,7 +6,7 @@ from yarl import URL
 
 
 def test_pickle():
-    u1 = URL("test")
+    u1 = URL("picklepickle")
     hash(u1)
     v = pickle.dumps(u1)
     u2 = pickle.loads(v)
@@ -16,9 +16,45 @@ def test_pickle():
 
 
 def test_default_style_state():
-    u = URL("test")
-    hash(u)
-    val = ("test", "test", "test", "test", "test")
+    u = object.__new__(URL)
+    val = ("set_state", "set_state", "set_state", "set_state", "set_state")
     u.__setstate__((None, {"_val": val, "_strict": False, "_cache": {"hash": 1}}))
-    assert not u._cache
     assert u._val == val
+    assert hash(u) != 1
+
+
+def test_empty_url_is_not_cached():
+    u = URL.__new__(URL)
+    val = ("set_state", "set_state", "set_state", "set_state", "set_state")
+    u.__setstate__((None, {"_val": val, "_strict": False, "_cache": {"hash": 1}}))
+    assert u._val == val
+    assert hash(u) != 1
+
+
+def test_pickle_does_not_pollute_cache():
+    """Verify the unpickling does not pollute the cache.
+
+    Since unpickle will call URL.__new__ with default
+    args, we need to make sure that default args never
+    end up in the pre_encoded_url or encode_url cache.
+    """
+    u1 = URL.__new__(URL)
+    u1._scheme = "this"
+    u1._netloc = "never.appears.any.where.else.in.tests"
+    u1._path = ""
+    u1._query = ""
+    u1._fragment = ""
+    hash(u1)
+    v = pickle.dumps(u1)
+    u2: URL = pickle.loads(v)
+    assert u1._cache
+    assert hash(u1) == hash(u2)
+    assert u2._scheme == "this"
+    assert u2._netloc == "never.appears.any.where.else.in.tests"
+    assert u2._path == ""
+    assert u2._query == ""
+    assert u2._fragment == ""
+    # Verify unpickling did not the cache wrong scheme
+    # for empty args.
+    assert URL().scheme == ""
+    assert URL("").scheme == ""
diff --git a/tests/test_url_benchmarks.py b/tests/test_url_benchmarks.py
index 4256481..3ca34f4 100644
--- a/tests/test_url_benchmarks.py
+++ b/tests/test_url_benchmarks.py
@@ -188,7 +188,7 @@ def test_raw_host_empty_cache(benchmark: BenchmarkFixture) -> None:
     @benchmark
     def _run() -> None:
         for _ in range(100):
-            url._cache = {}
+            url._cache.pop("raw_host", None)
             url.raw_host