Skip to main content

5.5 - Access Modifiers (Encapsulation)

Access modifiers are a key part of encapsulation in Object-Oriented Programming. They control the visibility and accessibility of classes, methods, properties, and fields, allowing you to hide implementation details and expose only what's necessary for other parts of your code to use.

What are Access Modifiers?

Access modifiers are keywords that specify the accessibility level of a type or member. They determine:

  1. Which parts of your code can see a particular class or member
  2. Which parts of your code can use a particular class or member

By carefully choosing access modifiers, you can:

  • Protect data from accidental modification
  • Hide implementation details
  • Create a clean, well-defined public API
  • Reduce dependencies between different parts of your code

Types of Access Modifiers in C#

C# provides several access modifiers, each with different levels of accessibility:

1. public

The public modifier makes a type or member accessible from anywhere, without restrictions.

public class Player
{
public string name;
public int score;

public void AddScore(int points)
{
score += points;
}
}

// Usage
Player player = new Player();
player.name = "Hero"; // Accessible
player.score = 100; // Accessible
player.AddScore(50); // Accessible

When to use public:

  • For members that need to be accessed from outside the class
  • For classes that need to be used throughout your application
  • For APIs that you want to expose to other parts of your code

2. private

The private modifier restricts access to within the containing class or struct. This is the most restrictive access level.

public class Player
{
public string name;
private int health;

public void TakeDamage(int amount)
{
health -= amount;
if (health < 0) health = 0;
}

private void Die()
{
Console.WriteLine($"{name} has been defeated!");
}
}

// Usage
Player player = new Player();
player.name = "Hero"; // Accessible
// player.health = 100; // Error: 'Player.health' is inaccessible
player.TakeDamage(50); // Accessible
// player.Die(); // Error: 'Player.Die()' is inaccessible

When to use private:

  • For implementation details that should be hidden
  • For fields that should only be modified through methods or properties
  • For helper methods that are only used within the class

3. protected

The protected modifier makes a member accessible within the containing class and all derived classes.

public class Character
{
public string name;
protected int health;

public void TakeDamage(int amount)
{
health -= amount;
if (health <= 0)
{
Die();
}
}

protected virtual void Die()
{
Console.WriteLine($"{name} has been defeated!");
}
}

public class Player : Character
{
private int lives;

public Player()
{
health = 100; // Accessible because it's protected in the base class
lives = 3;
}

protected override void Die()
{
lives--;
if (lives > 0)
{
Console.WriteLine($"{name} lost a life! {lives} remaining.");
health = 100;
}
else
{
base.Die(); // Call the base class implementation
}
}
}

// Usage
Player player = new Player();
player.name = "Hero"; // Accessible
// player.health = 100; // Error: 'Character.health' is inaccessible
player.TakeDamage(50); // Accessible
// player.Die(); // Error: 'Character.Die()' is inaccessible

When to use protected:

  • For members that should be accessible to derived classes
  • For methods that derived classes might need to override
  • When implementing inheritance hierarchies

4. internal

The internal modifier makes a type or member accessible only within the same assembly (DLL or executable).

// In Assembly1.dll
internal class GameUtility
{
internal static void LogDebug(string message)
{
Console.WriteLine($"DEBUG: {message}");
}
}

// In the same assembly
public class GameManager
{
public void Initialize()
{
GameUtility.LogDebug("Game initialized"); // Accessible
}
}

// In Assembly2.dll (different assembly)
public class ExternalSystem
{
public void DoSomething()
{
// GameUtility.LogDebug("External action"); // Error: 'GameUtility' is inaccessible
}
}

When to use internal:

  • For types or members that should only be used within your project
  • To hide implementation details from external assemblies
  • For utility classes that support your public API but shouldn't be exposed directly

5. protected internal

The protected internal modifier makes a member accessible within the same assembly and from derived classes in any assembly.

// In Assembly1.dll
public class BaseComponent
{
protected internal void Initialize()
{
Console.WriteLine("Component initialized");
}
}

// In Assembly2.dll
public class DerivedComponent : BaseComponent
{
public void Setup()
{
Initialize(); // Accessible because it's protected internal and this is a derived class
}
}

// In Assembly2.dll but not derived from BaseComponent
public class OtherComponent
{
public void Setup()
{
BaseComponent component = new BaseComponent();
// component.Initialize(); // Error: 'BaseComponent.Initialize()' is inaccessible
}
}

When to use protected internal:

  • For members that should be accessible to derived classes in other assemblies
  • When creating frameworks or libraries that will be extended by other assemblies

6. private protected (C# 7.2)

The private protected modifier makes a member accessible only within the containing class or derived classes in the same assembly.

// In Assembly1.dll
public class BaseComponent
{
private protected void Initialize()
{
Console.WriteLine("Component initialized");
}
}

// In the same assembly
public class DerivedComponent : BaseComponent
{
public void Setup()
{
Initialize(); // Accessible because it's private protected and this is a derived class in the same assembly
}
}

// In Assembly2.dll
public class ExternalDerivedComponent : BaseComponent
{
public void Setup()
{
// Initialize(); // Error: 'BaseComponent.Initialize()' is inaccessible
}
}

When to use private protected:

  • For members that should only be accessible to derived classes within the same assembly
  • When you want to restrict inheritance-based access to the current assembly

Default Access Modifiers

If you don't specify an access modifier:

  • Classes and interfaces are internal by default
  • Class members (fields, methods, properties) are private by default
  • Interface members are public by default
// This class is internal by default
class GameUtility
{
// This field is private by default
int counter;

// This method is private by default
void IncrementCounter()
{
counter++;
}
}

Access Modifiers in Unity

In Unity, access modifiers work the same way as in standard C#, but there are some Unity-specific considerations:

1. Inspector Visibility

Fields marked as public or with the [SerializeField] attribute are visible in the Unity Inspector:

public class PlayerController : MonoBehaviour
{
// Visible in Inspector
public float moveSpeed = 5f;

// Also visible in Inspector despite being private
[SerializeField] private float jumpForce = 10f;

// Not visible in Inspector
private float gravity = 9.81f;
}

This allows you to expose fields to the Unity Editor for configuration while still maintaining proper encapsulation in your code.

2. MonoBehaviour Message Methods

Unity's special message methods like Awake(), Start(), and Update() should typically be private or protected since they're called by the Unity engine, not by your code:

public class PlayerController : MonoBehaviour
{
// Correct: private Unity message methods
private void Awake()
{
// Initialization code
}

private void Update()
{
// Per-frame code
}

// Public methods for external access
public void TakeDamage(int amount)
{
// Implementation
}
}

3. Component Access

When accessing components on GameObjects, you often need public methods to interact with them:

public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int currentHealth;

private void Awake()
{
currentHealth = maxHealth;
}

// Public method for other components to call
public void TakeDamage(int amount)
{
currentHealth -= amount;
if (currentHealth <= 0)
{
Die();
}
}

// Private implementation detail
private void Die()
{
gameObject.SetActive(false);
}

// Public getter for health information
public int GetCurrentHealth()
{
return currentHealth;
}

public float GetHealthPercentage()
{
return (float)currentHealth / maxHealth;
}
}

// Usage from another component
public class Enemy : MonoBehaviour
{
public void Attack(GameObject target)
{
Health targetHealth = target.GetComponent<Health>();
if (targetHealth != null)
{
targetHealth.TakeDamage(10); // Call the public method

// Check health status
int remainingHealth = targetHealth.GetCurrentHealth();
float healthPercentage = targetHealth.GetHealthPercentage();

Debug.Log($"Target health: {remainingHealth} ({healthPercentage:P0})");
}
}
}

Practical Examples

Example 1: Player Stats System

// A well-encapsulated player stats system
public class PlayerStats
{
// Private fields - implementation details
private int baseHealth;
private int baseStrength;
private int baseDexterity;
private int baseIntelligence;
private List<StatusEffect> activeEffects = new List<StatusEffect>();

// Public constructor
public PlayerStats(int health, int strength, int dexterity, int intelligence)
{
baseHealth = health;
baseStrength = strength;
baseDexterity = dexterity;
baseIntelligence = intelligence;
}

// Public methods - the API
public int GetHealth()
{
return CalculateStat(baseHealth, StatType.Health);
}

public int GetStrength()
{
return CalculateStat(baseStrength, StatType.Strength);
}

public int GetDexterity()
{
return CalculateStat(baseDexterity, StatType.Dexterity);
}

public int GetIntelligence()
{
return CalculateStat(baseIntelligence, StatType.Intelligence);
}

public void AddStatusEffect(StatusEffect effect)
{
activeEffects.Add(effect);
}

public void RemoveStatusEffect(string effectName)
{
activeEffects.RemoveAll(e => e.Name == effectName);
}

// Private helper method - implementation detail
private int CalculateStat(int baseStat, StatType type)
{
int finalStat = baseStat;

foreach (StatusEffect effect in activeEffects)
{
if (effect.AffectedStat == type)
{
if (effect.IsPercentage)
{
finalStat = (int)(finalStat * (1 + effect.Value / 100f));
}
else
{
finalStat += effect.Value;
}
}
}

return finalStat;
}

// Private nested class - implementation detail
private enum StatType
{
Health,
Strength,
Dexterity,
Intelligence
}

// Public nested class - part of the API
public class StatusEffect
{
public string Name { get; private set; }
public StatType AffectedStat { get; private set; }
public int Value { get; private set; }
public bool IsPercentage { get; private set; }

public StatusEffect(string name, StatType stat, int value, bool isPercentage)
{
Name = name;
AffectedStat = stat;
Value = value;
IsPercentage = isPercentage;
}
}
}

// Usage
public class Player : MonoBehaviour
{
private PlayerStats stats;

private void Start()
{
stats = new PlayerStats(100, 10, 8, 12);

// Add a status effect
stats.AddStatusEffect(new PlayerStats.StatusEffect("Strength Potion", StatType.Strength, 20, true));

// Get the modified stat
int strength = stats.GetStrength();
Debug.Log($"Player strength: {strength}"); // Output: Player strength: 12
}
}

Example 2: Weapon System with Inheritance

// Base weapon class with protected members for derived classes
public abstract class Weapon
{
// Public properties - accessible to all
public string Name { get; protected set; }
public int Damage { get; protected set; }
public float Weight { get; protected set; }

// Protected field - accessible to derived classes
protected float durability;

// Protected constructor - only derived classes can instantiate
protected Weapon(string name, int damage, float weight, float initialDurability)
{
Name = name;
Damage = damage;
Weight = weight;
durability = initialDurability;
}

// Public methods - the API
public abstract void Attack();

public float GetDurability()
{
return durability;
}

public bool IsBroken()
{
return durability <= 0;
}

// Protected method - implementation detail for derived classes
protected void ReduceDurability(float amount)
{
durability -= amount;
if (durability < 0)
{
durability = 0;
OnWeaponBroken();
}
}

// Protected virtual method - can be overridden by derived classes
protected virtual void OnWeaponBroken()
{
Debug.Log($"{Name} has broken!");
}
}

// Derived weapon class
public class Sword : Weapon
{
// Private fields - specific to Sword
private float sharpness;

// Public constructor
public Sword(string name, int damage, float weight, float sharpness)
: base(name, damage, weight, 100f)
{
this.sharpness = sharpness;
}

// Public method implementation
public override void Attack()
{
Debug.Log($"Slashing with {Name} for {CalculateDamage()} damage!");
ReduceDurability(0.5f);
}

// Public method specific to Sword
public void Sharpen()
{
sharpness = Mathf.Min(sharpness + 0.2f, 1.0f);
Debug.Log($"{Name} has been sharpened. Sharpness: {sharpness:P0}");
}

// Private helper method
private int CalculateDamage()
{
return (int)(Damage * sharpness);
}

// Protected override
protected override void OnWeaponBroken()
{
base.OnWeaponBroken();
Debug.Log("The sword's blade has shattered!");
}
}

// Another derived weapon class
public class Bow : Weapon
{
// Private fields - specific to Bow
private int arrowCount;

// Public constructor
public Bow(string name, int damage, float weight, int initialArrows)
: base(name, damage, weight, 80f)
{
arrowCount = initialArrows;
}

// Public method implementation
public override void Attack()
{
if (arrowCount > 0)
{
Debug.Log($"Firing {Name} for {Damage} damage!");
arrowCount--;
ReduceDurability(0.2f);
}
else
{
Debug.Log("Out of arrows!");
}
}

// Public method specific to Bow
public void AddArrows(int count)
{
arrowCount += count;
Debug.Log($"Added {count} arrows. Total: {arrowCount}");
}

// Public getter
public int GetArrowCount()
{
return arrowCount;
}
}

// Usage
public class Player : MonoBehaviour
{
private Weapon currentWeapon;

private void Start()
{
// Create weapons
Sword sword = new Sword("Steel Sword", 15, 5f, 0.8f);
Bow bow = new Bow("Longbow", 12, 3f, 20);

// Use the sword
currentWeapon = sword;
currentWeapon.Attack(); // Output: Slashing with Steel Sword for 12 damage!

// Sword-specific method
sword.Sharpen(); // Output: Steel Sword has been sharpened. Sharpness: 100%

// Switch to bow
currentWeapon = bow;
currentWeapon.Attack(); // Output: Firing Longbow for 12 damage!

// Bow-specific method
bow.AddArrows(10); // Output: Added 10 arrows. Total: 29
}
}

Example 3: Game Manager with Singleton Pattern

// A singleton game manager with appropriate access modifiers
public class GameManager : MonoBehaviour
{
// Public static instance - accessible from anywhere
public static GameManager Instance { get; private set; }

// Public properties - part of the API
public bool IsGamePaused { get; private set; }
public int CurrentLevel { get; private set; }
public int PlayerScore { get; private set; }

// Private fields - implementation details
private float gameTime;
private List<GameObject> activeEnemies = new List<GameObject>();

// Private Unity message methods
private void Awake()
{
// Singleton pattern implementation
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
InitializeGame();
}
else
{
Destroy(gameObject);
}
}

private void Update()
{
if (!IsGamePaused)
{
gameTime += Time.deltaTime;
UpdateGameState();
}
}

// Public methods - the API
public void PauseGame()
{
IsGamePaused = true;
Time.timeScale = 0f;
Debug.Log("Game paused");
}

public void ResumeGame()
{
IsGamePaused = false;
Time.timeScale = 1f;
Debug.Log("Game resumed");
}

public void AddScore(int points)
{
PlayerScore += points;
Debug.Log($"Score: {PlayerScore}");
}

public void LoadLevel(int levelNumber)
{
CurrentLevel = levelNumber;
// Implementation details...
Debug.Log($"Loading level {levelNumber}");
}

public void RegisterEnemy(GameObject enemy)
{
activeEnemies.Add(enemy);
}

public void UnregisterEnemy(GameObject enemy)
{
activeEnemies.Remove(enemy);

if (activeEnemies.Count == 0)
{
OnAllEnemiesDefeated();
}
}

public float GetGameTime()
{
return gameTime;
}

// Private methods - implementation details
private void InitializeGame()
{
IsGamePaused = false;
CurrentLevel = 1;
PlayerScore = 0;
gameTime = 0f;
Debug.Log("Game initialized");
}

private void UpdateGameState()
{
// Implementation details...
}

private void OnAllEnemiesDefeated()
{
Debug.Log("All enemies defeated! Level complete.");
// Implementation details...
}
}

// Usage from other scripts
public class UIController : MonoBehaviour
{
private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
if (GameManager.Instance.IsGamePaused)
{
GameManager.Instance.ResumeGame();
}
else
{
GameManager.Instance.PauseGame();
}
}
}

public void DisplayGameInfo()
{
int level = GameManager.Instance.CurrentLevel;
int score = GameManager.Instance.PlayerScore;
float time = GameManager.Instance.GetGameTime();

Debug.Log($"Level: {level}, Score: {score}, Time: {time:F1}s");
}
}

Best Practices for Access Modifiers

  1. Start Restrictive: Begin with the most restrictive access level (private) and only increase accessibility as needed.

  2. Encapsulate Fields: Make fields private and provide controlled access through methods or properties.

  3. Minimal Public API: Expose only what other classes absolutely need to use.

  4. Use protected Carefully: Only make members protected if derived classes genuinely need to access or override them.

  5. Consider internal for Project-Specific Code: Use internal for classes that should only be used within your project.

  6. Document Public Members: Since public members form your API, document them thoroughly so others know how to use them.

  7. Use [SerializeField] in Unity: For fields that need to be visible in the Inspector but shouldn't be accessible from code, use [SerializeField] private instead of public.

Conclusion

Access modifiers are a powerful tool for implementing encapsulation in your C# code. By carefully controlling the visibility of your classes and their members, you can create more maintainable, robust, and secure code.

Remember that good encapsulation isn't about hiding everything—it's about creating a clear, well-defined interface for others to use while hiding the implementation details that might change.

In the next section, we'll explore properties, which provide a more elegant way to implement encapsulation for class data.

Practice Exercise

Exercise: Refactor the following poorly encapsulated class to use appropriate access modifiers:

// Before refactoring
public class Enemy
{
public string name;
public int health;
public int damage;
public float moveSpeed;
public bool isDead;

public void Update()
{
if (health <= 0)
{
isDead = true;
Die();
}
}

public void Die()
{
Console.WriteLine($"{name} has been defeated!");
}

public void Move()
{
if (!isDead)
{
Console.WriteLine($"{name} moves at speed {moveSpeed}");
}
}

public void Attack(Player player)
{
if (!isDead)
{
Console.WriteLine($"{name} attacks for {damage} damage!");
player.health -= damage;
}
}
}

public class Player
{
public string name;
public int health;
public bool isDead;
}

Consider:

  1. Which fields should be private?
  2. Which methods should be private or protected?
  3. How should other classes interact with this Enemy class?
  4. How can you improve the Player class to be properly encapsulated?