Node.js: работа с файловой системой

от автора

Привет, друзья!

Представляю вашему вниманию перевод этой замечательной статьи.

Данная статья включает в себя:

Если вам это интересно, прошу под кат.

Содержание

1. Концепции, паттерны и соглашения, используемые в ФС

В данном разделе предполагаются следующие импорты:

import * as fs from "node:fs"; import * as fsPromises from "node:fs/promises";

1.2. Стиль функций

ФС предоставляет 3 стиля функций:

  • синхронный стиль с обычными функциями:
    • fs.readFileSync(path, options?): string | Buffer;
  • 2 асинхронных стиля:
    • с функциями обратного вызова:
    • fs.readFile(path, options?, callback): void;
    • с функциями, возвращающими промисы:
    • fsPromises.readFile(path, options?): Promise<string | Buffer>.

1.1.1. Синхронные функции

Синхронные функции являются самыми простыми — они сразу возвращают значения и выбрасывают ошибки в виде исключений:

import * as fs from "node:fs";  try {   const result = fs.readFileSync("/etc/passwd", { encoding: "utf-8" });    console.log(result); } catch (err) {   console.error(err); }

В статье, в основном, используется данный стиль.

1.1.2. Функции, основанные на промисах

Такие функции возвращают промисы, которые разрешаются результатами и отклоняются с ошибками:

import * as fsPromises from "node:fs/promises";  try {   const result = await fsPromises.readFile(     "/etc/passwd", { encoding: "utf-8" });    console.log(result); } catch (err) {   console.error(err); }

Обратите внимание: промисифицированный (promisified) ФС импортируется из другого модуля.

1.1.3. Функции, основанные на колбэках

Такие функции передают результат и ошибки колбэку, передаваемому им в качестве последнего аргумента:

import * as fs from "node:fs";  fs.readFile("/etc/passwd", { encoding: "utf-8" },   (err, result) => {     if (err) {       console.error(err);       return;     }      console.log(result);   } );

Данный стиль в статье не используется (он считается устаревшим).

1.2. Доступ к файлам

  • все содержимое файла можно читать и записывать в виде строки;
  • потоки для чтения и записи позволяют обрабатывать файлы небольшими частями (чанками/chunks), по одной за раз. Потоки разрешают только последовательный доступ;
  • для последовательного и произвольного доступа могут использоваться файловые дескрипторы или FileHandles, отдаленно напоминающие потоки:
    • файловые дескрипторы — это целые числа, представляющие файлы. Они управляются с помощью следующих функций (у каждой синхронной версии имеется колбэк-эквивалент — fs.open() и т.п.):
    • fs.openSync(path, flags?, mode?): открывает новый дескриптор файла по указанному пути и возвращает его;
    • fs.closeSync(fd): закрывает дескриптор;
    • fs.fchmodSync(fd, mode);
    • fs.fchownSync(fd, uid, gid);
    • fs.fdatasyncSync(fd);
    • fs.fstatSync(fd, options?);
    • fs.fsyncSync(fd);
    • fs.ftruncateSync(fd, len?);
    • fs.futimesSync(fd, atime, mtime);
    • файловые дескрипторы могут использоваться синхронным и колбэк-ФС. Промис-ФС предоставляет абстракцию — класс FileHandle, основанный на дескрипторах. Экземпляры создаются с помощью fsPromises.open(). Операции выполняются с помощью таких методов (не функций), как:
    • fileHandle.close();
    • fileHandle.chmod(mode);
    • fileHandle.chown(uid, gid);
    • и др.

FileHandles в этой статье не рассматриваются.

1.3. Префиксы названий функций

1.3.1. Префикс «l»: символические ссылки

Функции, названия которых начинаются с буквы l, как правило, оперируют символическими ссылками:

  • fs.lchmodSync(), fs.lchmod(), fsPromises.lchmod();
  • fs.lchownSync(), fs.lchown(), fsPromises.lchown();
  • fs.lutimesSync(), fs.lutimes(), fsPromises.lutimes();
  • и др.

1.3.2. Префикс «f»: дескрипторы файлов

Функции, названия которых начинаются с буквы f, как правило, управляют файловыми дескрипторами:

  • fs.fchmodSync(), fs.fchmod();
  • fs.fchownSync(), fs.fchown();
  • fs.fstatSync(), fs.fstat();
  • и др.

1.4. Важные классы

1.4.1. URL: альтернатива строковым путям к файловой системе

Функции, принимающие строковые пути (1), как правило, также принимают экземпляры URL (2):

import * as fs from "node:fs";  assert.equal(   fs.readFileSync(     "/tmp/data.txt", { encoding: "utf-8" }), // (1)   "Текст" );  assert.equal(   fs.readFileSync(     new URL("file:///tmp/data.txt"), { encoding: "utf-8" }), // (2)   "Текст" );

Ручное преобразование путей в file: кажется простым, но необходимо учитывать большое количество нюансов: процентное кодирование и декодирование, управляющие символы, буквы дисков Windows и т.д. Поэтому лучше применять следующие функции:

URL будет рассмотрен в одной из следующих статей.

1.4.2. Буферы

Класс Buffer представляет последовательность байтов фиксированного размера. Он является подклассом Uint8Array (TypedArray — типизированного массива). Буферы используются, в основном, для работы с файлами, содержащими бинарные данные.

Функции, принимающие Buffer, также принимают Uint8Array. Поскольку Uint8Arrays являются кроссплатформенными, а Buffers нет, предпочтение следует отдавать первым.

Преимущество буферов состоит в возможности кодирования и декодирования текста в разные кодировки. Для кодирования или декодирования UTF-8 в Uint8Array можно использовать TextEncoder или TextDecoder. Эти классы доступны на большинстве JavaScript-платформ:

> new TextEncoder().encode("café") Uint8Array.of(99, 97, 102, 195, 169)  > new TextDecoder().decode(Uint8Array.of(99, 97, 102, 195, 169)) "café"

1.4.3. Потоки

Некоторые функции принимают или возвращают нативные потоки данных (native streams):

  • stream.Readable: класс для создания потоков для чтения. Модуль node:fs использует fs.ReadStream, который является подклассом stream.Readable;
  • stream.Writable: класс для создания потоков для записи. Модуль node:fs использует fs.WriteStream, который является подклассом stream.Writable.

Вместо нативных потоков можно использовать кроссплатформенные веб-потоки (web streams), о которых рассказывалось в одной из предыдущих статей.

2. Чтение и запись файлов

2.1. Синхронное чтение файла в строку (опционально: разбиение по строкам)

fs.readFileSync(path, options?) читает файл по указанному пути в строку (результат чтения файла возвращается в виде единой строки):

import * as fs from "node:fs";  assert.equal(   fs.readFileSync("data.txt", { encoding: "utf-8" }),   "несколько\r\nстрок\nтекста" );

Плюсы и минусы данного подхода (по сравнению с использованием потока):

  • +: файл читается синхронно, делается это легко. Подходит для многих случаев;
  • -: плохой выбор для больших файлов — обработке файла предшествует чтение всего содержимого файла.

2.1.1. Разбиение текста без включения разделителей строк

Следующий код разбивает текст построчно и удаляет разделители строк (line terminators):

const RE_EOL = /\r?\n/;  const splitLines = (str) => str.split(RE_EOL);  assert.deepEqual(   splitLines("несколько\r\nстрок\nтекста"),   ["несколько", "строк", "текста"] );

«EOL» расшифровывается как «end of line» (конец строки).

2.1.2. Разбиение текста с включением разделителей строк

const RE_EOL = /(?<=\r?\n)/; // (1)  const splitLinesWithEols = (str) => str.split(RE_EOL);  assert.deepEqual(   splitLinesWithEols("несколько\r\nстрок\nтекста"),   ["несколько\r\n", "строк\n", "текста"] ); assert.deepEqual(   splitLinesWithEols("первый\n\nтретий"),   ["первый\n", "\n", "третий"] ); assert.deepEqual(   splitLinesWithEols("EOL в конце\n"),   ["EOL в конце\n"] ); assert.deepEqual(   splitLinesWithEols(""),   [""] );

На строке 1 у нас имеется регулярное выражение с ретроспективной проверкой (lookbehind assertion). Оно сопоставляется с тем, что предшествует \r?\n, но ничего не захватывает. Поэтому разделители сохраняются.

На платформах, не поддерживающих ретроспективные проверки, можно использовать такую функцию:

function splitLinesWithEols(str) {   if (str.length === 0) return [""];    const lines = [];    let prevEnd = 0;    while (prevEnd < str.length) {     // Поиск "\n" также означает поиск "\r\n"     const newlineIndex = str.indexOf("\n", prevEnd);      // Перевод на новую строку включается в строку     const end = newlineIndex < 0 ? str.length : newlineIndex + 1;      lines.push(str.slice(prevEnd, end));      prevEnd = end;   }    return lines; }

2.2. Построчное чтение файла с помощью потока

import * as fs from "node:fs"; import { Readable } from "node:stream";  const nodeReadable = fs.createReadStream(   "text-file.txt",   { encoding: "utf-8" } );  const webReadableStream = Readable.toWeb(nodeReadable);  const lineStream = webReadableStream.pipeThrough(new ChunksToLinesStream());  for await (const line of lineStream) {   console.log(line); } /**  * несколько\r\n  * строк\n  * текста */

Вот, что здесь используется:

  • fs.createReadStream(path, options?): создает поток (экземпляр stream.Readable);
  • stream.Readable.toWeb(nodeReadable): преобразует доступный для чтения поток Node.js в веб-поток (экземпляр ReadableStream);
  • класс ChunksToLinesStream представляет поток для преобразования. Чанки — это небольшие части данных, производимые потоками. Если у нас есть поток, чанки которого представляют строки произвольной длины, и мы пропускает эти чанки через ChunksToLinesStream, то получаем поток с построчными чанками.

Веб-потоки являются асинхронно итерируемыми, что позволяет использовать цикл for-await-of для их перебора.

Плюсы и минусы данного подхода (по сравнению с чтением в строку):

  • +: хорошо подходит для больших файлов — данные могут обрабатываться инкрементально, не нужно ждать завершения чтения всего содержимого файла;
  • -: данные читаются асинхронно, код сложнее и его больше.

2.3. Синхронная запись строки в файл

fs.writeFileSync(path, str, options?) записывает строку в файл по указанному пути. Существующий файл перезаписывается:

import * as fs from "node:fs";  fs.writeFileSync(   "data.txt",   "Первая строка\nВторая строка\n",   { encoding: "utf-8" } );

Плюсы и минусы (по сравнению с потоком):

  • +: файл записывается синхронно, делается это легко. Подходит для многих случаев;
  • -: плохой выбор для больших файлов.

2.4. Синхронное добавление строки в файл

import * as fs from "node:fs";  fs.writeFileSync(   "data.txt",   "Новая строка\n",   { encoding: "utf-8", flag: "a" } );

Настройка flag со значением a означает, что мы добавляем данные. Другие возможные значения этой настройки.

Обратите внимание: в одних функциях настройка называется flag, в других — flags.

2.5. Запись нескольких строк в файл с помощью потока

import * as fs from "node:fs"; import { Writable } from "node:stream";  const nodeWritable = fs.createWriteStream(   "data.txt",   { encoding: "utf-8" } );  const webWritableStream = Writable.toWeb(nodeWritable);  const writer = webWritableStream.getWriter();  try {   await writer.write("Первая строка\n");   await writer.write("Вторая строка\n");   await writer.close(); } finally {   writer.releaseLock() }

Вот, что здесь используется:

Плюсы и минусы:

  • +: хорошо подходит для больших файлов;
  • -: запись выполняется асинхронно, код сложнее и его больше.

2.6. Добавление нескольких строк в файл с помощью потока

import * as fs from "node:fs"; import { Writable } from "node:stream";  const nodeWritable = fs.createWriteStream(   "data.txt",   // !   { encoding: "utf-8", flags: "a" } );  const webWritableStream = Writable.toWeb(nodeWritable);  const writer = webWritableStream.getWriter();  try {   await writer.write("Первая добавленная строка\n");   await writer.write("Вторая добавленная строка\n");   await writer.close(); } finally {   writer.releaseLock() }

3. Кроссплатформенная обработка разделителей строк

На разных платформах используются разные разделители строк, отмечающие конец строки:

  • на Windows — это \r\n;
  • на Unix\n.

Для кроссплатформенной обработки EOL можно использовать несколько стратегий.

3.1. Чтение разделителей строк

При обработке строк с EOL иногда бывает полезным их удалять:

const RE_EOL_REMOVE = /\r?\n$/;  function removeEol(line) {   const match = RE_EOL_REMOVE.exec(line);    if (!match) return line;    return line.slice(0, match.index); }  assert.equal(   removeEol("Windows EOL\r\n"),   "Windows EOL" ); assert.equal(   removeEol("Unix EOL\n"),   "Unix EOL" ); assert.equal(   removeEol("Без EOL"),   "Без EOL" );

3.2. Запись разделителей строк

Для записи разделителей строк в нашем распоряжении имеется 2 варианта:

  • константа EOL из модуля node:os содержит EOL текущей платформы;
  • можно регистрировать формат EOL входного файла и использовать этот формат при дальнейшей модификации данного файла.

4. Обход и создание директорий

4.1. Обход директории

Следующая функция обходит (traverse) директорию и возвращает список всех ее потомков (ее дочерних элементов, потомков дочерних элементов и т.д.):

import * as path from "node:path"; import * as fs from "node:fs";  function* traverseDir(dirPath) {   const dirEntries = fs.readdirSync(dirPath, {withFileTypes: true});    // Сортируем сущности для обеспечения большей предсказуемости   dirEntries.sort(     (a, b) => a.name.localeCompare(b.name, "en")   );    for (const dirEntry of dirEntries) {     const fileName = dirEntry.name;     const pathName = path.join(dirPath, fileName);     yield pathName;      if (dirEntry.isDirectory()) {       yield* traverseDir(pathName);     }   } }

Здесь:

  • fs.readdirSync(path, options?) возвращает потомков директории по указанному пути:
    • если настройка withFileTypes имеет значение true, функция возвращает записи каталога (directory entries), экземпляры fs.Dirent. Записи каталога содержат такие свойства, как:
    • dirent.name;
    • dirent.isDirectory();
    • dirent.isFile();
    • dirent.isSymbolicLink();
    • если настройка withFileTypes имеет значение true или не указана, функция возвращает список названий файлов.

Пример использования функции traverseDir:

for (const filePath of traverseDir("dir")) {   console.log(filePath); } /**  * dir/dir-file.txt  * dir/subdir  * dir/subdir/subdir-file1.txt  * dir/subdir/subdir-file2.csv */

4.2. Создание директории (mkdir, mkdir -p)

Для создания директорий можно использовать функцию fs.mkdirSync(path, options?).

options.recursive определяет, как создается директория по указанному пути:

  • если recursive имеет значение false или отсутствует, mkdirSync() возвращает undefined. Если директория (или файл) уже существует или отсутствует родительская директория, выбрасывается исключение;
  • если recursive имеет значение true, mkdirSync() возвращает путь первой созданной директории. Если директория (или файл) уже существует, ничего не происходит. Если отсутствует родительская директория, она создается.

Пример использования mkdirSync():

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",   ] );  fs.mkdirSync("dir/sub/subsub", { recursive: true });  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/sub",     "dir/sub/subsub",   ] );

4.3. Определение наличия директории

При создании вложенных директорий и файлов мы не всегда может быть уверены в существовании родительской директории. Следующая функция может в этом помочь:

import * as path from "node:path"; import * as fs from "node:fs";  function ensureParentDirectory(filePath) {   const parentDir = path.dirname(filePath);    if (!fs.existsSync(parentDir)) {     fs.mkdirSync(parentDir, { recursive: true });   } }

Пример использования:

assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",   ] );  const filePath = "dir/sub/subsub/new-file.txt";  ensureParentDirectory(filePath);  fs.writeFileSync(filePath, "content", { encoding: "utf-8" });  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/sub",     "dir/sub/subsub",     "dir/sub/subsub/new-file.txt",   ] );

4.4. Создание временной директории

fs.mkdtemp(pathPrefix, options?) создает временную директорию: она добавляет 6 произвольных символов к pathPrefix, создает директорию и возвращает путь.

Обратите внимание: pathPrefix не должен оканчиваться на заглавную X, поскольку некоторые платформы заменяют X произвольными символами.

Для создания временной директории внутри специфичной для операционной системы глобальной временной директории можно использовать функцию os.tmpdir:

import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs";  const pathPrefix = path.resolve(os.tmpdir(), "my-app");   // например, "/var/folders/ph/sz0384m11vxf/T/my-app"  const tmpPath = fs.mkdtempSync(pathPrefix);   // например, "/var/folders/ph/sz0384m11vxf/T/my-app1QXOXP"

Созданные таким способом директории автоматически не удаляются.

5. Копирование, переименование, перемещение файлов или директорий

5.1. Копирование файлов или директорий

fs.cpSync(srcPath, destPath, options?) копирует файл или директорию из srcPath в destPath. Полезные настройки:

  • recursive (false по умолчанию): директории (включая пустые) копируются только когда данная настройка имеет значение true;
  • force (true): если имеет значение true, существующие файлы перезаписываются:
    • если имеет значение false и настройка errorOnExist установлена в true, при наличии файла выбрасывается исключение;
  • filter: функция, позволяющая управлять тем, какие файлы копируются;
  • preserveTimestamps (false): если имеет значение true, копии в destPath получат отметки времени оригиналов (время создания, последней модификации и т.п.).

Пример использования:

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir-orig",     "dir-orig/some-file.txt",   ] );  fs.cpSync("dir-orig", "dir-copy", { recursive: true });  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir-copy",     "dir-copy/some-file.txt",     "dir-orig",     "dir-orig/some-file.txt",   ] );

5.2. Переименование или перемещение файлов или директорий

fs.renameSync(oldPath, newPath) переименовывает или перемещает файл или директорию из oldPath в newPath.

Пример использования данной функции для переименования директории:

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "old-dir-name",     "old-dir-name/some-file.txt",   ] );  fs.renameSync("old-dir-name", "new-dir-name");  assert.deepEqual(   Array.from(traverseDir(".")),   [     "new-dir-name",     "new-dir-name/some-file.txt",   ] );

Пример перемещения файла:

assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/subdir",     "dir/subdir/some-file.txt",   ] );  fs.renameSync("dir/subdir/some-file.txt", "some-file.txt");  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/subdir",     "some-file.txt",   ] );

6. Удаление файлов или директорий

6.1. Удаление файлов и директорий (rm, rm -r)

fs.rmSync(path, options?) удаляет файл или директорию по указанному пути. Полезные настройки:

  • recursive (false): директории (включая пустые) удаляются только когда данная настройка имеет значение true;
  • force (false): если имеет значение false, при отсутствии файла или директории по указанному пути выбрасывается исключение.

Пример удаления файла:

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/some-file.txt",   ] );  fs.rmSync("dir/some-file.txt");  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",   ] );

Пример рекурсивного удаления непустой директории:

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/subdir",     "dir/subdir/some-file.txt",   ] );  fs.rmSync("dir/subdir", {recursive: true});  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",   ] );

6.2. Удаление пустых директорий (rmdir)

fs.rmdirSync удаляет пустую директорию (если директория не является пустой, выбрасывается исключение):

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/subdir",   ] );  fs.rmdirSync("dir/subdir");  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",   ] );

6.3. Создание директорий

Следующая функция очищает директорию по указанному пути:

import * as path from "node:path"; import * as fs from "node:fs";  function clearDir(dirPath) {   for (const fileName of fs.readdirSync(dirPath)) {     const pathName = path.join(dirPath, fileName);      fs.rmSync(pathName, { recursive: true });   } }

Здесь:

  • fs.readdirSync(path) возвращает названия всех потомков директории по указанному пути;
  • fs.rmSync(path, options?) удаляет файлы и директории.

Пример использования:

assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/dir-file.txt",     "dir/subdir",     "dir/subdir/subdir-file.txt"   ] );  clearDirectory("dir");  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",   ] );

6.4. Перемещение файлов или директорий в корзину

Библиотека trash перемещает файлы или директории в корзину. Она работает на macOS, Windows и Linux.

Пример использования:

import trash from "trash";  await trash(["*.png", "!rainbow.png"]);

trash() принимает строку или массив строк в качестве первого параметра. Любая строка может быть паттерном поиска (glob pattern) (со звездочками и другими метасимволами).

7. Чтение и изменение записей файловой системы

7.1. Определение наличия файла или директории

fs.existsSync(path) возвращает true, если файл или директория по указанному пути существует:

import * as fs from "node:fs";  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/some-file.txt",   ] ); assert.equal(   fs.existsSync("dir"), true ); assert.equal(   fs.existsSync("dir/some-file.txt"), true ); assert.equal(   fs.existsSync("dir/non-existent-file.txt"), false );

7.2. Получение статистики файла: является ли файл директорией, когда он был создан и т.д.

fs.statSync(path, options?) возвращает экземпляр fs.Stats с полезной информацией о файле или директории по указанному пути. Основные настройки:

  • throwIfNoEntry (true): если true, при отсутствии записи выбрасывается исключение, если false, возвращается undefined;
  • bigint (false): если true, функция использует BigInt для числовых значений (таких как отметки времени, см. ниже).

Свойства экземпляров fs.Stats:

  • вид записи:
    • stats.isFile();
    • stats.isDirectory();
    • stats.isSymbolicLink();
  • stats.size: размер в байтах;
  • отметки времени:
    • 3 вида:
    • stats.atime: время последнего доступа;
    • stats.mtime: время последней модификации;
    • stats.birthtime: время создания;
    • каждый вид может использовать 3 единицы измерения, например, для atime:
    • stats.atime: экземпляр Date;
    • stats.atimeMS: миллисекунды с начала эпохи (POSIX);
    • stats.atimeNs: наносекунды с начала эпохи.

Пример реализации функции isDirectory с помощью fs.statsSync():

import * as fs from "node:fs";  function isDirectory(thePath) {   const stats = fs.statSync(thePath, { throwIfNoEntry: false });    return stats && stats.isDirectory(); }  assert.deepEqual(   Array.from(traverseDir(".")),   [     "dir",     "dir/some-file.txt",   ] ); assert.equal(   isDirectory("dir"), true ); assert.equal(   isDirectory("dir/some-file.txt"), false ); assert.equal(   isDirectory("non-existent-dir"), false );

7.3. Изменение атрибутов файла: разрешения, владелец, группа, отметки времени

Функции для модификации атрибутов файла:

8. Работа со ссылками

Функции для работы с жесткими ссылками (hard links):

  • fs.linkSync(existingPath, newPath) создает жесткую ссылку;
  • fs.unlinkSync(path) удаляет жесткую ссылку на файл и, возможно, сам файл, если удаленная ссылка была последней.

Функции для работы с символическими ссылками (symbolic links):

Следующие функции оперируют символическими ссылками без их разыменования (dereferencing) (обратите внимание на префикс l):

Еще одна полезная функция — fs.realpathSync(path, options?) вычисляет каноническое название пути посредством разрешения символов . и .., а также символических ссылок.

Настройки функций, влияющие на обработку символических ссылок:

  • fs.cpSync(srcPath, destPath, options?):
    • dereference (false): если true, копируется файл, на который указывает символическая ссылка, а не сама ссылка;
    • verbatimSymlinks (false): если false, обновляется указатель локации цели скопированной символической ссылки.

Ссылки для дальнейшего изучения материала.

Надеюсь, вы, как и я, узнали что-то новое и не зря потратили время.

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/678792/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *