что CS50 наконец учил меня про C

12 июля 2026 г. clearning

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

К тому времени я уже использовал Python годами (Django CRM, куча скраперов, bookbot от boot.dev) без когда-либо нужды думать об памяти как чём-то что я управляю. Python просто не просит тебя. C просит немедленно, и speller это задача что заставляет тебя действительно ответить.

Проблема, примерно

Загрузи словарь около 143 тысяч слов в хеш-таблицу, используя связные списки для коллизий, потом проверь орфографию текстовый файл против неё достаточно быстро чтоб check50 не заканчивалось по времени. Стартовой код даёт тебе заголовочный файл с сигнатурами функций: load, check, hash, size, unload. Всё остальное, ты строишь.

typedef struct node
{
    char word[LENGTH + 1];
    struct node *next;
}
node;

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

Компилирование и запуск моего первого реального примера указателя, перед speller

Моя первая хеш-функция, которая была плохо на цель без значения быть

CS50 говорит тебе вперёд что хеш-функция это где живёт скорость или умирает, и я не принял это серьёзно первый проход.

unsigned int hash(const char *word)
{
    return tolower(word[0]) % N;
}

Первая буква слова, модуль размер таблицы. Оно компилировалось. Оно даже бежало правильно, в смысле что check всё ещё вернул правильный ответ для каждого слова. Это просто заняло около сорока секунд чтоб загрузить и проверить действительный словарь, что check50 не считает прошедшим временем.

Что это действительно означало

Двадцать шесть букв разбросанные через как много бакетов я установил N означает каждый бакет держит тысячи слов в одном длинном связном списке, и поиск слова означает идти список целиком, один узел за раз, сравнивая строки. Хеш-таблица только быстра если хеширование действительно разбрасывает слова. Моя была функционально просто один гигантский связный список за начальную букву, это худшая версия вещи связные списки должны помочь с.

Исправление и где действительный баг всё ещё был

Я переключился на реальный стринг хеш, тот что смешивает каждый символ вместо просто первого.

unsigned int hash(const char *word)
{
    unsigned int h = 0;
    for (int i = 0; word[i] != '\0'; i++)
    {
        h = (h * 31) + tolower(word[i]);
    }
    return h % N;
}

Время загрузки упало с сорока секунд ниже двух. Та часть ощущалась отлично. Что не ощущалось отлично это segfault это представило в load(), который я не трогал, на том же самом словаре что работал отлично час назад.

Где это действительно сломалось

node *n = malloc(sizeof(node));
strcpy(n->word, word);
n->next = table[index];
table[index] = n;

Выглядело хорошо. Бежало хорошо на маленьком словаре. valgrind (что CS50 заставляет тебя использовать, не просто упомянуть на лекции) сказал мне ровно что было неправильно как только я запустил это против большого.

==12345== Invalid write of size 1
==12345==    at 0x1091A2: load (dictionary.c:47)
==12345==  Address 0x0 is not stack'd, malloc'd or (recently) free'd

Я не проверял возвращаемое значение malloc, и на большом словаре, достаточно глубоко в работу, одна выделение действительно не удалась. С плохой хеш-функцией это никогда не появилось, потому что всё было так медленно что давление на память никогда не строилось тем же самым способом перед тем как тестовая привязка отказалась ждать. Более быстрая хеш-функция означала больше выделений происходит в меньше времени, что означало я действительно попал неудачный случай мой старый, медленнее, хуже код все время случайно скрывал от меня.

Один if (n == NULL) return false; исправил это. Баг не был действительно об указателях вообще. Это было об допущении функция что может провалиться всегда добьется успеха, потому это сделало первый пятьдесят раз я её запустил, и делание остатка программы быстрее точно вид изменения что может выявить баг что всегда был там.

Второй баг, в полностью разной функции

unload() должен идти каждый бакет и освободить каждый узел. Мой выглядел, на меня, полностью правильно:

bool unload(void)
{
    for (int i = 0; i < N; i++)
    {
        node *cursor = table[i];
        while (cursor != NULL)
        {
            free(cursor);
            cursor = cursor->next;
        }
    }
    return true;
}

valgrind опять, терпеливо как всегда:

==12345== Invalid read of size 8
==12345==    at 0x109310: unload (dictionary.c:63)
==12345==  Address 0x51fc0a0 is 0 bytes inside a block of size 24 free'd

Чтение-после-освобождение. Я освобождал cursor, потом немедленно читал cursor->next из памяти которую я только что сказал распределителю он может переиспользовать. На этом конкретном запуске это случилось ещё держать правильное значение, прямо пока это случайно не сделало, что ровно вид бага что проходит локально и падает где-то в другом месте за никакую видимую причину. Исправление было сохранением следующего указателя перед освобождением текущего узла, не после.

node *cursor = table[i];
while (cursor != NULL)
{
    node *next = cursor->next;
    free(cursor);
    cursor = next;
}

Два бага, та же проблема, тот же корневой причина под обоими: я сохранял используя значение указателя после момента когда оно перестало быть безопасно доверять, один раз потому что функция что я вызвал могла молча провалиться, один раз потому что я уже сказал системе та память была свободна переиспользовать. C не стопит тебя делая любой. Это просто в конечном итоге показывает тебе, во время и место это выбирает, обычно не линия где действительная ошибка была сделана.

Меньший отступ, так как оставаться просыпаюсь на эту проблему стоит одной честной касательной

Где-то вокруг 1am на вторую ночь я убедил себя файл словаря сам был испорчен, потому что конкретный запуск сохранял ведение себя по-другому от идентично-выглядящего предыдущего запуска. Это не был файл. Это было что malloc неудача под давлением памяти вероятностно, не детерминировано, так что полностью одинаковый входной не всегда это инициирует в зависимости от что ещё машина случилось быть делающая. Я провел действительно постыдное количество времени diffing словарь файл против себя перед тем как я принял баг был в моем коде, не данные.

Что действительно застряло

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

Что я бы сделал по-другому на следующей проблеме

Запусти valgrind с самой первой работающей версии, не просто как только что-то сломалось. Оба бага здесь сидели в коде что выглядел хорошо и прошёл случайное тестирование в течение времени перед тем как действительно появилось. Жду краш идти ищи инструмент что поймал бы это ранее это привычка я хочу разучиться перед тем как это стоит мне больше чем одной поздней ночи.

Я всё ещё не полностью доверяю моей собственной арифметике указателя на массивах. Это проблема для более поздней проблемы.

0 комментариев

Войдите , чтобы оставить комментарий.

Войти

Забыли пароль?

Нет аккаунта?