JavaRush /Курси /C# SELF /Посилальні типи й типи значення

Посилальні типи й типи значення

C# SELF
Рівень 12 , Лекція 3
Відкрита

1. Вступ

Уявіть, що у вас є коробка. Якщо це коробка, яка містить сам предмет (наприклад, яблуко всередині), то це нагадує тип значення. Дані зберігаються просто в цій «коробці» (змінній).

А тепер уявіть, що у вас є візитка з адресою. Сама візитка не є домом — вона лише вказує, де знайти дім. Це нагадує посилальний тип. Змінна в цьому разі містить не самі дані, а «візитку» — адресу в памʼяті, де ці дані розташовані.

Прості приклади:

  • int x = 5; // Тип значення: змінна x «зберігає» число 5 безпосередньо.
  • string name = "Вася"; // Посилальний тип: змінна name «зберігає» посилання на рядок «Вася», який міститься десь у памʼяті.

Хто є хто?

Щоб вам було простіше орієнтуватися, наведімо загальний перелік того, до яких категорій належать основні типи даних:

Типи значення (Value Types):
  • Примітивні типи: int, double, float, bool, char, byte, short, long, decimal тощо.
  • Структури (struct): Усі користувацькі структури, які ви оголошуєте за допомогою ключового слова struct.
  • Перерахування (enum): Типи, що дають змогу визначити набір іменованих констант.
Посилальні типи (Reference Types):
  • Рядки (string): Хоч рядки мають деякі особливості (вони незмінні), це посилальний тип.
  • Усі масиви: Наприклад, int[], string[], YourCustomClass[].
  • Усі класи (class): Усі користувацькі класи, які ви оголошуєте за допомогою ключового слова class.
  • Делегати (delegate): Типи, що представляють посилання на методи.
  • Інтерфейси (interface): Хоч самі інтерфейси не є обʼєктами, змінна типу інтерфейсу може зберігати посилання на обʼєкт, який реалізує цей інтерфейс.
  • Списки, словники та інші колекції: Наприклад, List<T>, Dictionary<TKey, TValue>.
  • І загалом, усе, що не є struct або enum, за замовчуванням є посилальним типом у C#.

2. Копіювання змінних

Тут і криється основна практична різниця. Коли ви присвоюєте одну змінну іншій, що саме копіюється?

А) Копіювання типу значення (Value Type)

Коли ви копіюєте тип значення, створюється повноцінна, незалежна копія самих даних. Це як зробити ксерокопію документа: зміни в одній копії не впливають на оригінал чи інші копії.


    int a = 10;
int b = a; 					// b тепер теж 10, але це "своя копія"
Console.WriteLine($"Початкові значення: a = {a}, b = {b}"); // Початкові значення: a = 10, b = 10

b = 15; 					// Змінюємо b
Console.WriteLine($"Після зміни b: a = {a}, b = {b}"); // Після зміни b: a = 10, b = 15

Пояснення: Змінна b отримала власну копію значення 10. Коли b було змінено на 15, a залишилося зі своїм первинним значенням 10. Вони цілком незалежні.

Б) Копіювання посилального типу (Reference Type)

Під час копіювання посилального типу копіюється лише посилання, тобто «адреса» обʼєкта в памʼяті. Обидві змінні тепер вказують на один і той самий обʼєкт. Це схоже на ситуацію, коли ви дали двом людям одну й ту саму візитку: вони обидва знатимуть адресу одного й того самого дому. Якщо одна людина змінить щось у домі (наприклад, перефарбує стіну), інша, прийшовши за цією адресою, побачить ті самі зміни.

Розгляньмо на прикладі масивів, адже на них добре видно «магію» посилань (на відміну від рядків, які мають свої особливості).


int[] arr1 = {1, 2, 3};
int[] arr2 = arr1; // arr2 і arr1 вказують на один і той самий масив у пам’яті!

// Початкові значення: arr1[0] = 1, arr2[0] = 1
Console.WriteLine($"Початкові значення: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

arr2[0] = 42; // Змінюємо елемент масиву через arr2

// Після зміни arr2[0]: arr1[0] = 42, arr2[0] = 42
Console.WriteLine($"Після зміни arr2[0]: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

Пояснення: Обидві змінні (arr1 і arr2) містять посилання на один і той самий масив у памʼяті. Коли ви змінюєте елемент arr2[0], ви фактично змінюєте той самий масив, на який вказують обидві змінні. Тому arr1[0] також показує змінене значення.

Нюанс із рядками (string)

Рядки в C# є посилальним типом, але поводяться трохи інакше через свою незмінність (immutability). Це означає, що після створення рядок змінити не можна. Будь-яка операція, яка, здавалося б, «змінює» рядок (наприклад, конкатенація, Replace()), насправді створює новий рядок у памʼяті.


string str1 = "Hello";
string str2 = str1; // str2 посилається на той самий об’єкт "Hello", що й str1

// str1 = "Hello", str2 = "Hello"
Console.WriteLine($"Початкові значення: str1 = \"{str1}\", str2 = \"{str2}\""); 

str2 = "Bye"; // Тут створюється НОВИЙ об’єкт "Bye", і str2 починає посилатися на нього

// str1 = "Hello", str2 = "Bye"
Console.WriteLine($"Після зміни str2: str1 = \"{str1}\", str2 = \"{str2}\""); 

Пояснення: Спочатку str1 і str2 посилалися на один і той самий обʼєкт «Hello». Коли str2 було присвоєно «Bye», C# не змінив наявний обʼєкт «Hello». Замість цього він створив новий обʼєкт «Bye» у памʼяті, і str2 тепер посилається на цей новий обʼєкт. str1, як і раніше, посилається на старий обʼєкт «Hello». Це важлива відмінність, що часто бентежить початківців.

3. Таблиця основних відмінностей

Характеристика Тип значення
(наприклад, struct, int)
Посилальний тип
(наприклад, class, string, масив)
Що копіюється Саме значення («ксерокопія» даних) Посилання на обʼєкт («адреса» у памʼяті)
Звʼязок між копіями Немає, копії повністю незалежні. Зміна однієї не впливає на іншу. Так, усі посилання вказують на один і той самий обʼєкт. Зміну обʼєкта, зроблену через одне посилання, видно всім.
Може бути null? Ні (крім nullable-типів, як-от int?). Завжди має значення. Так. Може вказувати «у нікуди» (null). Спроба доступу до обʼєкта зі значенням null спричинить NullReferenceException.
Як оголошується struct, а також усі примітивні типи (int, bool тощо), enum class, interface, delegate, array, string, object
Механізм очищення Автоматично видаляються зі стека під час виходу з області видимості. Очищаються збирачем сміття (Garbage Collector), коли на них більше немає посилань.

4. Приклад у застосунку

Уявімо, що ми розробляємо простий консольний застосунок для анкети користувача. Маємо структуру для балів іспиту та клас для профілю користувача.

// Значимий тип: структура для зберігання балів
struct Score
{
    public int Points;
    public string Grade; // Додамо для наочності
}

// Посилальний тип: клас для профілю користувача
class User
{
    public string Name;
    public Score ExamScore; // Вкладена структура
}

Копіювання структури (Score)

Score score1 = new Score { Points = 100, Grade = "A" };
Score score2 = score1; // Копіюється весь вміст score1 у score2
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=100, Grade=A

score2.Points = 88;
score2.Grade = "B";

Console.WriteLine("--- Після зміни score2 ---");
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A (не змінився!)
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=88, Grade=B

Результат: score1 залишився незмінним. Це сталося тому, що під час Score score2 = score1; вміст score1 (усі його поля) було скопійовано до score2. Обидві змінні тепер містять свої незалежні набори даних.

Копіювання класу (User)

User u1 = new User 
{ 
    Name = "Анна", 
    ExamScore = new Score { Points = 95, Grade = "A" } 
};
User u2 = u1; // Тепер і u2, і u1 посилаються на ОДНОГО й того ж користувача в пам’яті!
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}"); // u1: Name=Анна, Score=95
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}"); // u2: Name=Анна, Score=95

u2.Name = "Іван"; // Змінюємо ім’я через u2
u2.ExamScore.Points = 60; // Змінюємо бали через u2
u2.ExamScore.Grade = "C";

Console.WriteLine("--- Після зміни u2 ---");
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}, Grade={u1.ExamScore.Grade}"); // u1: Name=Іван, Score=60, Grade=C
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}, Grade={u2.ExamScore.Grade}"); // u2: Name=Іван, Score=60, Grade=C

Результат: u1 також змінився! Це сталося тому, що u1 і u2 спочатку посилалися на один і той самий обʼєкт User у памʼяті. Коли ми змінили властивості обʼєкта через u2 (наприклад, u2.Name = "Іван";), ми фактично змінили сам обʼєкт. Тому, звертаючись до u1.Name, ми бачимо вже змінене значення. Навіть вкладена структура ExamScore змінилася для u1, бо вона є частиною обʼєкта User, на який вказують обидва посилання.

5. Що відбувається під час передавання в методи

Розуміння того, як типи передаються в методи, є критично важливим для передбачуваної поведінки програми.

Передавання типу значення (Value Type) в метод

Під час передавання типу значення в метод за замовчуванням відбувається передавання за значенням. Це означає, що метод отримує копію вихідної змінної. Будь-які зміни, зроблені всередині методу з цією копією, не впливають на оригінал за межами методу.

void AddTen(int x)
{
    Console.WriteLine($"Всередині методу (до зміни): x = {x}"); // Всередині методу (до зміни): x = 5
    x = x + 10; // x тепер 15, але це локальна копія
    Console.WriteLine($"Всередині методу (після зміни): x = {x}"); // Всередині (після зміни): x = 15
    // Ця локальна копія 'x' "помре" після виходу з методу.
}

int num = 5;
Console.WriteLine($"До виклику методу: num = {num}"); // До виклику методу: num = 5
AddTen(num);
Console.WriteLine($"Після виклику методу: num = {num}"); // Після виклику методу: num = 5 (не змінився!)

Результат: Змінна num залишилася незмінною (5). x усередині AddTen — це зовсім окрема змінна, ініціалізована копією значення num.

Передавання посилального типу (Reference Type) в метод

Під час передавання посилального типу в метод за замовчуванням також відбувається передавання за значенням, але копіюється значення посилання, а не сам обʼєкт. Це означає, що всередині методу ви отримуєте копію «візитки» (адреси) на оригінальний обʼєкт. Обидва посилання (оригінальне і те, що всередині методу) вказують на один і той самий обʼєкт у памʼяті.

void RenameUser(User u)
{
    // Всередині методу (до зміни): u.Name = "Ольга"
    Console.WriteLine($"Всередині методу (до зміни): u.Name = \"{u.Name}\""); 
    u.Name = "Нове ім’я"; // Змінюємо властивість об’єкта, на який вказує 'u'
    // Всередині методу (після зміни): u.Name = "Нове ім’я"
    Console.WriteLine($"Всередині методу (після зміни): u.Name = \"{u.Name}\""); 
}

User user = new User { Name = "Ольга" };
// До виклику методу: user.Name = "Ольга"
Console.WriteLine($"До виклику методу: user.Name = \"{user.Name}\""); 
RenameUser(user); 
// Після виклику методу: user.Name = "Нове ім’я"
Console.WriteLine($"Після виклику методу: user.Name = \"{user.Name}\""); 

Результат: Імʼя користувача змінилося на «Нове імʼя». Метод RenameUser отримав копію посилання на обʼєкт user. Через цю копію посилання метод зміг отримати доступ до оригінального обʼєкта у купі й змінити його властивість Name.

Важливе доповнення: Що буде, якщо всередині методу ми присвоїмо новий обʼєкт переданій змінній посилального типу?

void ReassignUser(User u)
{
    u = new User { Name = "Зовсім новий користувач" }; // 'u' тепер посилається на новий об’єкт
    Console.WriteLine($"Всередині методу (після переприсвоєння): u.Name = \"{u.Name}\"");
}

User originalUser = new User { Name = "Оригінальний користувач" };
Console.WriteLine($"До виклику ReassignUser: originalUser.Name = \"{originalUser.Name}\"");
ReassignUser(originalUser); 
Console.WriteLine($"Після виклику ReassignUser: originalUser.Name = \"{originalUser.Name}\""); // "Оригінальний користувач" - не змінився!

Результат: originalUser не змінився! Це тому, що ReassignUser отримав копію посилання. Коли u = new User(...) сталося всередині методу, локальна змінна u почала вказувати на зовсім новий обʼєкт. Оригінальне посилання originalUser, як і раніше, вказує на той самий старий обʼєкт. Це дуже важливий момент!

6. Типові помилки початківців і їхні причини

Розуміння посилальних типів і типів значення може бути складним завданням для початківців. Ось деякі поширені помилки та хибні уявлення:

Плутанина з копіюванням масивів: Початківці часто очікують, що під час присвоєння arr2 = arr1; буде створено незалежну копію масиву. Насправді це лише два посилання на один і той самий масив. Це як два пульти керування до однієї й тієї самої гри: що б ви не натиснули на одному пульті, це вплине на гру і буде видно й на іншому. Щоб створити незалежну копію масиву, його потрібно явно клонувати (наприклад, int[] arr2 = (int[])arr1.Clone(); або використовувати методи Copy).

Очікування зміни рядків «за посиланням»: Через те, що string є посилальним типом, іноді вважають, що він поводитиметься як масив щодо змін. Однак через незмінність рядків будь-яка операція, яка здається зміною, насправді створює новий рядковий обʼєкт. Це часто призводить до неочікуваних результатів і може бути неефективним під час багаторазових операцій із рядками в циклі (для цього краще використовувати StringBuilder).

Забування про null: Типи значення, окрім nullable-типів (int?, bool?), завжди мають якесь значення і ніколи не можуть бути null. Посилальні ж типи можуть бути null, тобто не вказувати ні на який обʼєкт. Спроба отримати доступ до члена обʼєкта, який має значення null, призведе до сумнозвісного NullReferenceException. Завжди перевіряйте посилальні змінні на null перед використанням, якщо є ймовірність, що вони можуть бути невизначеними.

Використання класів замість структур для дрібних даних: Іноді за звичкою все оголошують класами. Для маленьких, простих наборів даних, що становлять одне ціле (наприклад, точка Point { X, Y }, колір Color { R, G, B }), структури можуть бути продуктивнішими, адже вони зберігаються у стеку й копіюються за значенням, що знижує накладні витрати на збирач сміття. Однак структури мають бути незмінними, невеликими та не повинні містити поля-посилання, які можуть бути null або змінювати свій стан.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ