라인 / oop

4 posts

line

코드 품질 개선 기법 28편: 제약 조건에도 상속세가 발생한다 (opens in new tab)

LY Corporation’s technical review highlights that making a class open for inheritance imposes a "tax" on its internal constraints, particularly immutability. While developers often use inheritance to create specialized versions of a class, doing so with immutable types can allow subclasses to inadvertently or intentionally break the parent class's guarantees. To ensure strict data integrity, the post concludes that classes intended to be immutable should be made final or designed around read-only interfaces rather than open for extension. ### The Risks of Open Immutable Classes * Kotlin developers often wrap `IntArray` in an `ImmutableIntList` to avoid the overhead of boxed types while ensuring the collection remains unchangeable. * If `ImmutableIntList` is marked as `open`, a developer might create a `MutableIntList` subclass that adds a `set` method to modify the internal `protected valueArray`, violating the "Immutable" contract of the parent type. * Even if the internal state is `private`, a subclass can override the `get` method to return dynamic or state-dependent values, effectively breaking the expectation that the data remains constant. * These issues demonstrate that any class with a "fundamental" name should be carefully guarded against unexpected inheritance in different modules or packages. ### Establishing Safe Inheritance Hierarchies * Mutable objects should not inherit from immutable objects, as this inherently violates the immutability constraints established by the parent. * Conversely, immutable objects should not inherit from mutable ones; this often leads to runtime errors (such as `UnsupportedOperationException`) when a user attempts to call modification methods like `add` or `set` on an immutable instance. * The most effective design pattern is to use a "read-only" (unmodifiable) interface as a common parent, similar to how Kotlin distinguishes between `List` and `MutableList`. * In this structure, mutable classes can inherit from the read-only parent without issue (adding new methods), and immutable classes can inherit from the read-only parent while adding stricter internal constraints. To maintain high code quality and prevent logic errors, developers should default to making classes final when immutability is a core requirement. If shared functionality is needed across different types of lists, utilize composition or a shared read-only interface to ensure that the "immutable" label remains a truthful guarantee.

line

코드 품질 개선 기법 24편: 유산의 가치 (opens in new tab)

The LY Corporation Review Committee advocates for simplifying code by avoiding unnecessary inheritance when differences between classes are limited to static data rather than dynamic logic. By replacing complex interfaces and subclasses with simple data models and specific instances, developers can reduce architectural overhead and improve code readability. This approach ensures that configurations, such as UI themes, remain predictable and easier to maintain without the baggage of a type hierarchy. ### Limitations of Inheritance-Based Configuration * The initial implementation used a `FooScreenThemeStrategy` interface to define UI elements like background colors, text colors, and icons. * Specific themes (Light and Dark) were implemented as separate classes that overridden the interface properties. * This pattern creates an unnecessary proliferation of types when the only difference between the themes is the specific value of the constants being returned. * Using inheritance for simple value changes makes the code harder to follow and can lead to over-engineering. ### Valid Scenarios for Inheritance * **Dynamic Logic:** When behavior needs to change dynamically at runtime via dynamic dispatch. * **Sum Types:** Implementing restricted class hierarchies, such as Kotlin `sealed` classes or Java's equivalent. * **Decoupling:** Separating interface from implementation to satisfy DI container requirements or to improve build speeds. * **Dependency Inversion:** Applying architectural patterns to resolve circular dependencies or to enforce one-way dependency flows. ### Transitioning to Data Models and Instantiation * Instead of an interface, a single "final" class or data class (e.g., `FooScreenThemeModel`) should be defined to hold the required properties. * Individual themes are created as simple instances of this model rather than unique subclasses. * In Kotlin, defining a class without the `open` keyword ensures that the properties are not dynamically altered and that no hidden, instance-specific logic is introduced. * This "instantiation over inheritance" strategy guarantees that properties remain static and the code remains concise. To maintain a clean codebase, prioritize data-driven instantiation over class-based inheritance whenever logic remains constant. This practice reduces the complexity of the type system and makes the code more resilient to unintended side effects.

line

코드 품질 개선 기법 21편: 생성자를 두드려 보고 건너라 (opens in new tab)

Designing objects that require a specific initialization sequence often leads to fragile code and runtime exceptions. When a class demands that a method like `prepare()` be called before its primary functionality becomes available, it places the burden of safety on the consumer rather than the structure of the code itself. To improve reliability, developers should aim to create "unbreakable" interfaces where an instance is either ready for use upon creation or restricted by the type system from being used incorrectly. ### Problems with "Broken" Constructors * Classes that allow instantiation in an "unprepared" state rely on documentation or developer memory to avoid `IllegalStateException` errors. * When an object is passed across different layers of an application, it becomes difficult to track whether the required setup logic has been executed. * Relying on runtime checks to verify internal state increases the surface area for bugs that only appear during specific execution paths. ### Immediate Initialization and Factory Patterns * The most direct solution is to move initialization logic into the `init` block, allowing properties to be defined as read-only (`val`). * Because constructors have limitations—such as the inability to use `suspend` functions or handle complex side effects—a private constructor combined with a static factory method (e.g., `companion object` in Kotlin) is often preferred. * Using a factory method like `createInstance()` ensures that all necessary preparation logic is completed before a user ever receives the object instance. ### Lazy and Internal Preparation * If the initialization process is computationally expensive and might not be needed for every instance, "lazy" initialization can defer the cost until the first time a functional method is called. * In Kotlin, the `by lazy` delegate can be used to encapsulate preparation logic, ensuring it only runs once and remains thread-safe. * Alternatively, the class can handle preparation internally within its main methods, checking the initialization state automatically so the user does not have to manage it manually. ### Type-Safe State Transitions * For complex lifecycles, the type system can be used to enforce order by splitting the object into two distinct classes: one for the "unprepared" state and one for the "prepared" state. * The initial class contains only the `prepare()` method, which returns a new instance of the "Prepared" class upon completion. * This approach makes it a compile-time impossibility to call methods like `play()` on an object that hasn't been prepared, effectively eliminating a whole category of runtime errors. ### Recommendations When designing classes with internal states, prioritize structural safety by making it impossible to represent an invalid state. Use factory functions for complex setup logic and consider splitting classes into separate types if they have distinct "ready" and "not ready" phases to leverage the compiler for error prevention.

line

코드 품질 개선 기법 13편: 클론 가족 (opens in new tab)

The "Clone Family" anti-pattern occurs when two parallel inheritance hierarchies—such as a data model tree and a provider tree—share an implicit relationship that is not enforced by the type system. This structure often leads to type-safety issues and requires risky downcasting to access specific data types, increasing the likelihood of runtime errors during code modifications. To resolve this, developers should replace rigid inheritance with composition or utilize parametric polymorphism to explicitly link related types. ## The Risks of Implicit Correspondence Maintaining two separate inheritance trees where individual subclasses are meant to correspond to one another creates several technical hurdles. * **Downcasting Requirements:** Because a base provider typically returns a base data model type, developers must manually cast the result to a specific subclass (e.g., `as FooDataModel`), which bypasses compiler safety. * **Lack of Type Enforcement:** The constraint that a specific provider always returns a specific model is purely implicit; the compiler cannot prevent a provider from returning the wrong model type. * **Fragile Architecture:** As the system grows, ensuring that "Provider A" always maps to "Model A" becomes difficult to audit, leading to potential bugs when new developers join the project or when the hierarchy is extended. ## Substituting Inheritance with Composition When the primary goal of inheritance is simply to share common logic, such as fetching raw data, using composition or aggregation is often a superior alternative. * **Logic Extraction:** Shared functionality can be moved into a standalone class, such as an `OriginalDataProvider`, which is then held as a private property within specific provider classes. * **Direct Type Returns:** By removing the shared parent class, each provider can explicitly return its specific data model type without needing a common interface. * **Decoupling:** This approach eliminates the "Clone Family" entirely by removing the need for parallel trees, resulting in cleaner and more modular code. ## Leveraging Parametric Polymorphism In scenarios where a common parent class is necessary—for example, to manage a collection of providers within a shared lifecycle—generics can be used to bridge the two hierarchies safely. * **Generic Type Parameters:** By defining the parent as `ParentProvider<T>`, the base class can use a type parameter for its return values rather than a generic base model. * **Subclass Specification:** Each implementation (e.g., `FooProvider : ParentProvider<FooDataModel>`) explicitly defines its return type, allowing the compiler to enforce the relationship. * **Flexible Constraints:** Developers can still utilize type bounds, such as `ParentProvider<T : CommonDataModel>`, to ensure that the generics adhere to a specific interface while maintaining type safety for callers. When designing data providers and models, avoid creating parallel structures that rely on implicit assumptions. Prioritize composition to simplify the architecture, or use generics if inheritance is required, ensuring that the relationships between classes remain explicit and verifiable by the compiler.